From ecfc96739aad94053bab03154ad62c4a16f8263d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Sat, 1 Mar 2025 01:16:27 +0100 Subject: [PATCH 1/4] feat(controller): hard fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- .github/ISSUE_TEMPLATE/bug.md | 33 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature-request.md | 15 + .github/actions/exists/action.yaml | 21 + .github/configs/ct.yaml | 9 + .github/configs/lintconf.yaml | 54 ++ .github/workflows/check-actions.yaml | 24 + .github/workflows/check-commit.yml | 23 + .github/workflows/check-pr.yml | 37 ++ .github/workflows/coverage.yml | 89 +++ .github/workflows/docker-build.yml | 44 ++ .github/workflows/docker-publish.yml | 59 ++ .github/workflows/e2e.yaml | 40 ++ .github/workflows/helm-publish.yml | 52 ++ .github/workflows/helm-test.yml | 58 ++ .github/workflows/lint.yaml | 52 ++ .github/workflows/releaser.yml | 32 + .gitignore | 37 ++ .golangci.yml | 59 ++ .goreleaser.yml | 106 ++++ .ko.yaml | 6 + .pre-commit-config.yaml | 41 ++ LICENSE | 574 ++++++++++++------ Makefile | 262 ++++++++ README.md | 44 ++ charts/cortex-tenant/.helmignore | 26 + charts/cortex-tenant/.schema.yaml | 3 + charts/cortex-tenant/Chart.yaml | 21 + charts/cortex-tenant/README.md | 123 ++++ charts/cortex-tenant/README.md.gotmpl | 77 +++ charts/cortex-tenant/artifacthub-repo.yml | 4 + charts/cortex-tenant/ci/test-values.yaml | 2 + charts/cortex-tenant/templates/_helpers.tpl | 90 +++ .../templates/configuration.yaml | 9 + .../cortex-tenant/templates/deployment.yaml | 100 +++ charts/cortex-tenant/templates/hpa.yaml | 42 ++ charts/cortex-tenant/templates/pdb.yaml | 13 + charts/cortex-tenant/templates/rbac.yaml | 78 +++ charts/cortex-tenant/templates/rules.yaml | 20 + charts/cortex-tenant/templates/service.yaml | 15 + .../templates/serviceaccount.yaml | 12 + .../templates/servicemonitor.yaml | 46 ++ charts/cortex-tenant/values.schema.json | 441 ++++++++++++++ charts/cortex-tenant/values.yaml | 260 ++++++++ cmd/main.go | 138 +++++ commitlint.config.cjs | 16 + config.yml | 28 + docs/README.md | 10 + docs/configuration.md | 120 ++++ docs/development.md | 81 +++ docs/images/capsule-cortex.gif | Bin 0 -> 98280 bytes docs/images/logo.png | Bin 0 -> 174384 bytes docs/monitoring.md | 26 + docs/overview.md | 19 + e2e/e2e_suite_test.go | 14 + e2e/kind.yaml | 7 + e2e/objects/distro/capsule.flux.yaml | 50 ++ e2e/objects/distro/cert-manager.flux.yaml | 38 ++ e2e/objects/distro/kustomization.yaml | 7 + e2e/objects/distro/metrics.flux.yaml | 38 ++ e2e/objects/flux/kustomization.yaml | 33 + e2e/suite_client_test.go | 60 ++ e2e/suite_test.go | 52 ++ e2e/utils_test.go | 117 ++++ go.mod | 97 +++ go.sum | 278 +++++++++ internal/config/config.go | 85 +++ internal/config/config_suite_test.go | 13 + internal/config/config_test.go | 80 +++ internal/controllers/reconciler.go | 75 +++ internal/metrics/recorder.go | 96 +++ internal/processor/processor.go | 453 ++++++++++++++ internal/processor/processor_suite_test.go | 13 + internal/processor/processor_test.go | 319 ++++++++++ internal/stores/store.go | 59 ++ internal/stores/store_test.go | 80 +++ renovate.json | 45 ++ 77 files changed, 5504 insertions(+), 201 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 .github/actions/exists/action.yaml create mode 100644 .github/configs/ct.yaml create mode 100644 .github/configs/lintconf.yaml create mode 100644 .github/workflows/check-actions.yaml create mode 100644 .github/workflows/check-commit.yml create mode 100644 .github/workflows/check-pr.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/docker-build.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/e2e.yaml create mode 100644 .github/workflows/helm-publish.yml create mode 100644 .github/workflows/helm-test.yml create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/releaser.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 .goreleaser.yml create mode 100644 .ko.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 Makefile create mode 100644 README.md create mode 100644 charts/cortex-tenant/.helmignore create mode 100644 charts/cortex-tenant/.schema.yaml create mode 100644 charts/cortex-tenant/Chart.yaml create mode 100644 charts/cortex-tenant/README.md create mode 100644 charts/cortex-tenant/README.md.gotmpl create mode 100644 charts/cortex-tenant/artifacthub-repo.yml create mode 100644 charts/cortex-tenant/ci/test-values.yaml create mode 100644 charts/cortex-tenant/templates/_helpers.tpl create mode 100644 charts/cortex-tenant/templates/configuration.yaml create mode 100644 charts/cortex-tenant/templates/deployment.yaml create mode 100644 charts/cortex-tenant/templates/hpa.yaml create mode 100644 charts/cortex-tenant/templates/pdb.yaml create mode 100644 charts/cortex-tenant/templates/rbac.yaml create mode 100644 charts/cortex-tenant/templates/rules.yaml create mode 100644 charts/cortex-tenant/templates/service.yaml create mode 100644 charts/cortex-tenant/templates/serviceaccount.yaml create mode 100644 charts/cortex-tenant/templates/servicemonitor.yaml create mode 100644 charts/cortex-tenant/values.schema.json create mode 100644 charts/cortex-tenant/values.yaml create mode 100644 cmd/main.go create mode 100644 commitlint.config.cjs create mode 100644 config.yml create mode 100644 docs/README.md create mode 100644 docs/configuration.md create mode 100644 docs/development.md create mode 100644 docs/images/capsule-cortex.gif create mode 100644 docs/images/logo.png create mode 100644 docs/monitoring.md create mode 100644 docs/overview.md create mode 100644 e2e/e2e_suite_test.go create mode 100644 e2e/kind.yaml create mode 100644 e2e/objects/distro/capsule.flux.yaml create mode 100644 e2e/objects/distro/cert-manager.flux.yaml create mode 100644 e2e/objects/distro/kustomization.yaml create mode 100644 e2e/objects/distro/metrics.flux.yaml create mode 100644 e2e/objects/flux/kustomization.yaml create mode 100644 e2e/suite_client_test.go create mode 100644 e2e/suite_test.go create mode 100644 e2e/utils_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/config_suite_test.go create mode 100644 internal/config/config_test.go create mode 100644 internal/controllers/reconciler.go create mode 100644 internal/metrics/recorder.go create mode 100644 internal/processor/processor.go create mode 100644 internal/processor/processor_suite_test.go create mode 100644 internal/processor/processor_test.go create mode 100644 internal/stores/store.go create mode 100644 internal/stores/store_test.go create mode 100644 renovate.json diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md new file mode 100644 index 0000000..dd4c9a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve the addon +title: '' +labels: blocked-needs-validation, bug +assignees: '' + +--- + +# Bug description + +A clear and concise description of what the bug is. + +# How to reproduce + +Steps to reproduce the behavior: + +1. Relevant Translator manifests +2. Relevant ArgoAddon manifests + +# Expected behavior + +A clear and concise description of what you expected to happen. + +# Logs + +If applicable, please provide logs: + +# Additional context + +- Addon version: +- Argo version: +- Kubernetes version: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..55ab036 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Chat on Slack + url: https://kubernetes.slack.com/archives/C03GETTJQRL + about: Maybe chatting with the community can help diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 0000000..a4d47ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,15 @@ +--- +name: Feature request +about: Suggest a new feature for the addon +title: '' +labels: blocked-needs-validation, feature +assignees: '' + +--- + +# Describe the feature + +A clear and concise description of the feature. + +# Expected behavior +A clear and concise description of what you expect to happen. diff --git a/.github/actions/exists/action.yaml b/.github/actions/exists/action.yaml new file mode 100644 index 0000000..4f11f55 --- /dev/null +++ b/.github/actions/exists/action.yaml @@ -0,0 +1,21 @@ +name: Checks if an input is defined + +description: Checks if an input is defined and outputs 'true' or 'false'. + +inputs: + value: + description: value to test + required: true + +outputs: + result: + description: outputs 'true' or 'false' if input value is defined or not + value: ${{ steps.check.outputs.result }} + +runs: + using: composite + steps: + - shell: bash + id: check + run: | + echo "result=${{ inputs.value != '' }}" >> $GITHUB_OUTPUT diff --git a/.github/configs/ct.yaml b/.github/configs/ct.yaml new file mode 100644 index 0000000..83b62df --- /dev/null +++ b/.github/configs/ct.yaml @@ -0,0 +1,9 @@ +remote: origin +target-branch: main +chart-dirs: + - charts/ +validate-chart-schema: false +validate-maintainers: false +validate-yaml: true +exclude-deprecated: true +check-version-increment: false diff --git a/.github/configs/lintconf.yaml b/.github/configs/lintconf.yaml new file mode 100644 index 0000000..eea1309 --- /dev/null +++ b/.github/configs/lintconf.yaml @@ -0,0 +1,54 @@ +--- +ignore: + - config/ + - charts/*/templates/ + - charts/**/templates/ + - docs/** + - hack/** +rules: + truthy: + level: warning + allowed-values: + - "true" + - "false" + - "on" + - "off" + check-keys: false + braces: + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + brackets: + min-spaces-inside: 0 + max-spaces-inside: 0 + min-spaces-inside-empty: -1 + max-spaces-inside-empty: -1 + colons: + max-spaces-before: 0 + max-spaces-after: 1 + commas: + max-spaces-before: 0 + min-spaces-after: 1 + max-spaces-after: 1 + comments: + require-starting-space: true + min-spaces-from-content: 1 + document-end: disable + document-start: disable # No --- to start a file + empty-lines: + max: 2 + max-start: 0 + max-end: 0 + hyphens: + max-spaces-after: 1 + indentation: + spaces: consistent + indent-sequences: whatever # - list indentation will handle both indentation and without + check-multi-line-strings: false + key-duplicates: enable + line-length: disable # Lines can be any length + new-line-at-end-of-file: enable + new-lines: + type: unix + trailing-spaces: enable diff --git a/.github/workflows/check-actions.yaml b/.github/workflows/check-actions.yaml new file mode 100644 index 0000000..4335d5c --- /dev/null +++ b/.github/workflows/check-actions.yaml @@ -0,0 +1,24 @@ +name: Check actions +permissions: {} + +on: + push: + branches: + - '*' + pull_request: + branches: + - "main" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Ensure SHA pinned actions + uses: zgosalvez/github-actions-ensure-sha-pinned-actions@25ed13d0628a1601b4b44048e63cc4328ed03633 # v3.0.22 + with: + # slsa-github-generator requires using a semver tag for reusable workflows. + # See: https://github.com/slsa-framework/slsa-github-generator#referencing-slsa-builders-and-generators + allowlist: | + slsa-framework/slsa-github-generator diff --git a/.github/workflows/check-commit.yml b/.github/workflows/check-commit.yml new file mode 100644 index 0000000..cda195c --- /dev/null +++ b/.github/workflows/check-commit.yml @@ -0,0 +1,23 @@ +name: Check Commit +permissions: {} + +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + commit_lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml new file mode 100644 index 0000000..f27c6f4 --- /dev/null +++ b/.github/workflows/check-pr.yml @@ -0,0 +1,37 @@ +name: "Check Pull Request" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: write + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@40166f00814508ec3201fc8595b393d451c8cd80 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + chore + ci + docs + feat + fix + test + sec + requireScope: false + wip: false + # If the PR only contains a single commit, the action will validate that + # it matches the configured pattern. + validateSingleCommit: true + # Related to `validateSingleCommit` you can opt-in to validate that the PR + # title matches a single commit to avoid confusion. + validateSingleCommitMatchesPrTitle: true diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..43c41c0 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,89 @@ +name: Coverage + +on: + push: + branches: + - "main" + pull_request: + types: + - opened + - reopened + - synchronize + branches: + - "main" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + compliance: + name: "License Compliance" + runs-on: ubuntu-24.04 + steps: + - name: "Checkout Code" + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Check secret + id: checksecret + uses: ./.github/actions/exists + with: + value: ${{ secrets.FOSSA_API_KEY }} + - name: "Run FOSSA Scan" + if: steps.checksecret.outputs.result == 'true' + uses: fossas/fossa-action@93a52ecf7c3ac7eb40f5de77fd69b1a19524de94 # v1.5.0 + with: + api-key: ${{ secrets.FOSSA_API_KEY }} + - name: "Run FOSSA Test" + if: steps.checksecret.outputs.result == 'true' + uses: fossas/fossa-action@93a52ecf7c3ac7eb40f5de77fd69b1a19524de94 # v1.5.0 + with: + api-key: ${{ secrets.FOSSA_API_KEY }} + run-tests: true + sast: + name: "SAST" + runs-on: ubuntu-24.04 + env: + GO111MODULE: on + permissions: + security-events: write + actions: read + contents: read + steps: + - name: Checkout Source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + with: + go-version-file: 'go.mod' + - name: Run Gosec Security Scanner + uses: securego/gosec@43fee884f668c23601e0bec7a8c095fba226f889 # v2.22.1 + with: + args: '-no-fail -fmt sarif -out gosec.sarif ./...' + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@1bb15d06a6fbb5d9d9ffd228746bf8ee208caec8 + with: + sarif_file: gosec.sarif + unit_tests: + name: "Unit tests" + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + with: + go-version-file: 'go.mod' + - name: Unit Test + run: make test + - name: Check secret + id: checksecret + uses: ./.github/actions/exists + with: + value: ${{ secrets.CODECOV_TOKEN }} + - name: Upload Report to Codecov + if: ${{ steps.checksecret.outputs.result == 'true' }} + uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: projectcapsule/cortex-tenant + files: ./coverage.out + fail_ci_if_error: true + verbose: true diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..7cfeb7c --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,44 @@ +name: Build images +permissions: {} +on: + pull_request: + branches: + - "*" + paths: + - '.github/workflows/docker-*.yml' + - 'api/**' + - 'internal/**' + - 'e2e/*' + - '.ko.yaml' + - 'go.*' + - 'main.go' + - 'Makefile' + +jobs: + build-images: + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: ko build + run: VERSION=${{ github.sha }} make ko-build-all + - name: Trivy Scan Image + uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0 + with: + scan-type: 'fs' + ignore-unfixed: true + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + env: + # Trivy is returning TOOMANYREQUESTS + # See: https://github.com/aquasecurity/trivy-action/issues/389#issuecomment-2385416577 + TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2' + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@1bb15d06a6fbb5d9d9ffd228746bf8ee208caec8 + with: + sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..9ef8cb5 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,59 @@ +name: Publish images +permissions: {} +on: + push: + tags: + - "v*" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + publish-images: + runs-on: ubuntu-latest + permissions: + packages: write + id-token: write + outputs: + container-digest: ${{ steps.publish.outputs.digest }} + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: "Extract Version" + id: extract_version + run: | + GIT_TAG=${GITHUB_REF##*/} + VERSION=${GIT_TAG##v} + echo "Extracted version: $VERSION" + echo "version=$VERSION" >> $GITHUB_OUTPUT + - name: Install Cosign + uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3.8.0 + - name: Publish with KO + id: publish + uses: peak-scale/github-actions/make-ko-publish@a441cca016861c546ab7e065277e40ce41a3eb84 # v0.2.0 + with: + makefile-target: ko-publish-all + registry: ghcr.io + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} + repository: ${{ github.repository_owner }} + version: ${{ steps.extract_version.outputs.version }} + sign-image: true + sbom-name: cortex-tenant + sbom-repository: ghcr.io/${{ github.repository_owner }}/cortex-tenant + signature-repository: ghcr.io/${{ github.repository_owner }}/cortex-tenant + main-path: ./cmd/ + env: + REPOSITORY: ${{ github.repository }} + generate-provenance: + needs: publish-images + permissions: + id-token: write # To sign the provenance. + packages: write # To upload assets to release. + actions: read # To read the workflow path. + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0 + with: + image: ghcr.io/${{ github.repository_owner }}/cortex-tenant + digest: "${{ needs.publish-images.outputs.container-digest }}" + registry-username: ${{ github.actor }} + secrets: + registry-password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..d39ea4b --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -0,0 +1,40 @@ +name: e2e +permissions: {} + +on: + pull_request: + branches: + - "*" + paths: + - '.github/workflows/e2e.yml' + - 'api/**' + - 'cmd/**' + - 'internal/**' + - 'e2e/*' + - '.ko.yaml' + - 'go.*' + - 'main.go' + - 'Makefile' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + kind: + name: Kubernetes + strategy: + fail-fast: false + matrix: + k8s-version: + - "" + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + with: + go-version-file: 'go.mod' + - name: e2e testing + run: KIND_K8S_VERSION="${{ matrix.k8s-version }}" make e2e diff --git a/.github/workflows/helm-publish.yml b/.github/workflows/helm-publish.yml new file mode 100644 index 0000000..cc9e403 --- /dev/null +++ b/.github/workflows/helm-publish.yml @@ -0,0 +1,52 @@ +name: Publish charts +permissions: read-all +on: + push: + tags: + - "v*" +jobs: + publish-helm: + runs-on: ubuntu-24.04 + permissions: + contents: write + id-token: write + packages: write + outputs: + chart-digest: ${{ steps.helm_publish.outputs.digest }} + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3.8.0 + - name: "Extract Version" + id: extract_version + run: | + GIT_TAG=${GITHUB_REF##*/} + VERSION=${GIT_TAG##v} + echo "version=$(echo $VERSION)" >> $GITHUB_OUTPUT + - name: Helm | Publish + id: helm_publish + uses: peak-scale/github-actions/helm-oci-chart@a441cca016861c546ab7e065277e40ce41a3eb84 # v0.2.0 + with: + registry: ghcr.io + repository: ${{ github.repository_owner }}/charts + name: "cortex-tenant" + path: "./charts/cortex-tenant/" + app-version: ${{ steps.extract_version.outputs.version }} + version: ${{ steps.extract_version.outputs.version }} + registry-username: ${{ github.actor }} + registry-password: ${{ secrets.GITHUB_TOKEN }} + update-dependencies: 'false' # Defaults to false + sign-image: 'true' + signature-repository: ghcr.io/${{ github.repository_owner }}/charts/cortex-tenant + helm-provenance: + needs: publish-helm + permissions: + id-token: write # To sign the provenance. + packages: write # To upload assets to release. + actions: read # To read the workflow path. + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0 + with: + image: ghcr.io/${{ github.repository_owner }}/charts/cortex-tenant + digest: "${{ needs.publish-helm.outputs.chart-digest }}" + registry-username: ${{ github.actor }} + secrets: + registry-password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/helm-test.yml b/.github/workflows/helm-test.yml new file mode 100644 index 0000000..6b837a5 --- /dev/null +++ b/.github/workflows/helm-test.yml @@ -0,0 +1,58 @@ +name: Test charts +permissions: {} + +on: + pull_request: + branches: + - "main" + paths: + - '.github/configs/**' + - '.github/workflows/helm-*.yml' + - 'charts/**' + - 'Makefile' + +jobs: + linter-artifacthub: + runs-on: ubuntu-latest + container: + image: artifacthub/ah + options: --user root + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Run ah lint + working-directory: ./charts/ + run: ah lint + lint: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4 + - name: Run chart-testing (lint) + run: make helm-lint + - name: Run docs-testing (helm-docs) + id: helm-docs + run: | + make helm-docs + if [[ $(git diff --stat) != '' ]]; then + echo -e '\033[0;31mDocumentation outdated! (Run make helm-docs locally and commit)\033[0m ❌' + git diff --color + exit 1 + else + echo -e '\033[0;32mDocumentation up to date\033[0m ✔' + fi + - name: Run schema-testing (helm-schema) + id: helm-schema + run: | + make helm-schema + if [[ $(git diff --stat) != '' ]]; then + echo -e '\033[0;31mSchema outdated! (Run make helm-schema locally and commit)\033[0m ❌' + git diff --color + exit 1 + else + echo -e '\033[0;32mSchema up to date\033[0m ✔' + fi + - name: Run chart-testing (install) + run: make helm-test diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..954569f --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,52 @@ +name: Linting +permissions: {} +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + manifests: + name: diff + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + with: + go-version-file: 'go.mod' + - name: Generate manifests + run: | + make manifests + if [[ $(git diff --stat) != '' ]]; then + echo -e '\033[0;31mManifests outdated! (Run make manifests locally and commit)\033[0m ❌' + git diff --color + exit 1 + else + echo -e '\033[0;32mDocumentation up to date\033[0m ✔' + fi + yamllint: + name: yamllint + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install yamllint + run: pip install yamllint + - name: Lint YAML files + run: yamllint -c=.github/configs/lintconf.yaml . + golangci: + name: lint + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 + with: + go-version-file: 'go.mod' + - name: Run golangci-lint + run: make golint diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml new file mode 100644 index 0000000..8e4207c --- /dev/null +++ b/.github/workflows/releaser.yml @@ -0,0 +1,32 @@ +name: Go Release + +permissions: {} +on: + push: + tags: + - 'v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: creekorful/goreportcard-action@1f35ced8cdac2cba28c9a2f2288a16aacfd507f9 # v1.0 + - uses: anchore/sbom-action/download-syft@79202aee38a39bd2039be442e58d731b63baf740 + - name: Install Cosign + uses: sigstore/cosign-installer@c56c2d3e59e4281cc41dea2217323ba5694b171e # v3.8.0 + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 # v6.2.1 + with: + version: latest + args: release --clean --timeout 90m + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0798dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/* +hack/kubeconfs/ +Dockerfile.cross +coverage.out +.DS_Store +dist + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Kubernetes Generated files - skip generated files, except for vendored files + +!vendor/**/zz_generated.* + +# editor and IDE paraphernalia +.idea +.vscode +*.swp +*.swo +*~ + +.hugo_build.lock +public/ +*.tgz +.cache/ +.tmp +*trivy* diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..53c8278 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,59 @@ +linters-settings: + dupl: + threshold: 100 + goconst: + min-len: 2 + min-occurrences: 2 + cyclop: + max-complexity: 27 + gocognit: + min-complexity: 50 + gci: + sections: + - standard + - default + - prefix(github.com/projectcapsule/cortex-tenant) + inamedparam: + # Skips check for interface methods with only a single parameter. + # Default: false + skip-single-param: true + nakedret: + # Make an issue if func has more lines of code than this setting, and it has naked returns. + max-func-lines: 50 +linters: + enable-all: true + disable: + - err113 + - depguard + - perfsprint + - funlen + - gochecknoinits + - lll + - gochecknoglobals + - mnd + - nilnil + - recvcheck + - unparam + - paralleltest + - ireturn + - testpackage + - varnamelen + - wrapcheck + - exhaustruct + - nonamedreturns + - gomoddirectives +issues: + exclude-rules: + - path: "internal/*" + linters: + - dupl + - lll + exclude-files: + - "zz_.*\\.go$" + - ".+\\.generated.go" + - ".+_test.go" + - ".+_test_.+.go" +run: + timeout: 3m + allow-parallel-runners: true + tests: false diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..a37a2ed --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,106 @@ +project_name: capsule +env: + - COSIGN_EXPERIMENTAL=true + - GO111MODULE=on +before: + hooks: + - go mod download +gomod: + proxy: false +builds: + - main: . + binary: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + env: + - CGO_ENABLED=0 + goarch: + - amd64 + - arm64 + goos: + - linux + flags: + - -trimpath + mod_timestamp: '{{ .CommitTimestamp }}' + ldflags: + - >- + -X main.Version={{ .Tag }} + -X main.GitCommit={{ .Commit }} + -X main.GitTag={{ .Tag }} + -X main.GitDirty={{ .Date }} + -X main.BuildTime={{ .Date }} + -X main.GitRepo={{ .ProjectName }} +release: + prerelease: auto + footer: | + **Full Changelog**: https://github.com/projectcapsule/{{ .ProjectName }}/compare/{{ .PreviousTag }}...{{ .Tag }} + + **Docker Images** + - `ghcr.io/projectcapsule/{{ .ProjectName }}:{{ .Version }}` + - `ghcr.io/projectcapsule/{{ .ProjectName }}:latest` + + **Helm Chart** + View this release on [Artifact Hub](https://artifacthub.io/packages/helm/projectcapsule/capsule/{{ .Version }}) or use the OCI helm chart: + + - `ghcr.io/projectcapsule/charts/{{ .ProjectName }}:{{ .Version }}` + + [Review the Major Changes section first before upgrading to a new version](https://artifacthub.io/packages/helm/projectcapsule/capsule/{{ .Version }}#major-changes) + + **Kubernetes compatibility** + + [!IMPORTANT] + Note that the Capsule project offers support only for the latest minor version of Kubernetes. + Backwards compatibility with older versions of Kubernetes and OpenShift is [offered by vendors](https://projectcapsule.dev/support/). + + | Kubernetes version | Minimum required | + |--------------------|------------------| + | `v1.31` | `>= 1.31.0` | + + + Thanks to all the contributors! 🚀 🦄 + extra_files: + - glob: ./capsule-seccomp.json +checksum: + name_template: 'checksums.txt' +changelog: + sort: asc + use: github + filters: + exclude: + - '^test:' + - '^chore' + - '^rebase:' + - 'merge conflict' + - Merge pull request + - Merge remote-tracking branch + - Merge branch + groups: + # https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional + - title: '🛠 Dependency updates' + regexp: '^.*?(feat|fix)\(deps\)!?:.+$' + order: 300 + - title: '✨ New Features' + regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$' + order: 100 + - title: '🐛 Bug fixes' + regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$' + order: 200 + - title: '📖 Documentation updates' + regexp: ^.*?docs(\([[:word:]]+\))??!?:.+$ + order: 400 + - title: '🛡️ Security updates' + regexp: ^.*?(sec)(\([[:word:]]+\))??!?:.+$ + order: 500 + - title: '🚀 Build process updates' + regexp: ^.*?(build|ci)(\([[:word:]]+\))??!?:.+$ + order: 600 + - title: '📦 Other work' + order: 9999 +sboms: + - artifacts: archive +signs: +- cmd: cosign + args: + - "sign-blob" + - "--output-signature=${signature}" + - "${artifact}" + - "--yes" + artifacts: all diff --git a/.ko.yaml b/.ko.yaml new file mode 100644 index 0000000..caf9dfa --- /dev/null +++ b/.ko.yaml @@ -0,0 +1,6 @@ +defaultPlatforms: +- linux/amd64 +- linux/arm64 +builds: +- id: cortex-tenant + main: ./cmd/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1aa5f22 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,41 @@ +repos: +- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.21.0 + hooks: + - id: commitlint + stages: [commit-msg] + additional_dependencies: ['@commitlint/config-conventional', 'commitlint-plugin-function-rules'] +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-executables-have-shebangs + - id: double-quote-string-fixer + - id: end-of-file-fixer + - id: trailing-whitespace +- repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: [-c=.github/configs/lintconf.yaml] +- repo: local + hooks: + - id: run-helm-docs + name: Execute helm-docs + entry: make helm-docs + language: system + files: ^charts/ + - id: run-helm-schema + name: Execute helm-schema + entry: make helm-schema + language: system + files: ^charts/ + - id: run-helm-lint + name: Execute helm-lint + entry: make helm-lint + language: system + files: ^charts/ + - id: golangci-lint + name: Execute golangci-lint + entry: make golint + language: system + files: \.go$ diff --git a/LICENSE b/LICENSE index 261eeb9..a612ad9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,373 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..10850b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,262 @@ +# Version +GIT_HEAD_COMMIT ?= $(shell git rev-parse --short HEAD) +VERSION ?= $(or $(shell git describe --abbrev=0 --tags --match "v*" 2>/dev/null),$(GIT_HEAD_COMMIT)) +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) + +# Defaults +REGISTRY ?= ghcr.io +REPOSITORY ?= projectcapsule/cortex-tenant +GIT_TAG_COMMIT ?= $(shell git rev-parse --short $(VERSION)) +GIT_MODIFIED_1 ?= $(shell git diff $(GIT_HEAD_COMMIT) $(GIT_TAG_COMMIT) --quiet && echo "" || echo ".dev") +GIT_MODIFIED_2 ?= $(shell git diff --quiet && echo "" || echo ".dirty") +GIT_MODIFIED ?= $(shell echo "$(GIT_MODIFIED_1)$(GIT_MODIFIED_2)") +GIT_REPO ?= $(shell git config --get remote.origin.url) +BUILD_DATE ?= $(shell git log -1 --format="%at" | xargs -I{} sh -c 'if [ "$(shell uname)" = "Darwin" ]; then date -r {} +%Y-%m-%dT%H:%M:%S; else date -d @{} +%Y-%m-%dT%H:%M:%S; fi') +IMG_BASE ?= $(REPOSITORY) +IMG ?= $(IMG_BASE):$(VERSION) +FULL_IMG ?= $(REGISTRY)/$(IMG_BASE) + +KIND_K8S_VERSION ?= "v1.30.0" +KIND_K8S_NAME ?= "cortex-tenant" + +## Tool Binaries +KUBECTL ?= kubectl +HELM ?= helm + +# Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) +ifeq (,$(shell go env GOBIN)) +GOBIN=$(shell go env GOPATH)/bin +else +GOBIN=$(shell go env GOBIN) +endif + +#################### +# -- Golang +#################### + +.PHONY: golint +golint: golangci-lint + $(GOLANGCI_LINT) run -c .golangci.yml + +.PHONY: golint +golint-fix: golangci-lint + $(GOLANGCI_LINT) run -c .golangci.yml --fix + +all: manager + +# Run tests +.PHONY: test +test: test-clean test-clean + @GO111MODULE=on go test -v $(shell go list ./... | grep -v "e2e") -coverprofile coverage.out + +.PHONY: test-clean +test-clean: ## Clean tests cache + @go clean -testcache + +# Build manager binary +manager: generate golint + go build -o bin/manager + +# Run against the configured Kubernetes cluster in ~/.kube/config +run: + go run . + +#################### +# -- Docker +#################### + +KO_PLATFORM ?= linux/$(GOARCH) +KOCACHE ?= /tmp/ko-cache +KO_REGISTRY := ko.local +KO_TAGS ?= "latest" +ifdef VERSION +KO_TAGS := $(KO_TAGS),$(VERSION) +endif + +LD_FLAGS := "-X main.Version=$(VERSION) \ + -X main.GitCommit=$(GIT_HEAD_COMMIT) \ + -X main.GitTag=$(VERSION) \ + -X main.GitTreeState=$(GIT_MODIFIED) \ + -X main.BuildDate=$(BUILD_DATE) \ + -X main.GitRepo=$(GIT_REPO)" + +# Docker Image Build +# ------------------ + +.PHONY: ko-build-controller +ko-build-controller: ko + @echo Building Controller $(FULL_IMG) - $(KO_TAGS) >&2 + @LD_FLAGS=$(LD_FLAGS) KOCACHE=$(KOCACHE) KO_DOCKER_REPO=$(FULL_IMG) \ + $(KO) build ./cmd/ --bare --tags=$(KO_TAGS) --push=false --local --platform=$(KO_PLATFORM) + +.PHONY: ko-build-all +ko-build-all: ko-build-controller + +# Docker Image Publish +# ------------------ + +REGISTRY_PASSWORD ?= dummy +REGISTRY_USERNAME ?= dummy + +.PHONY: ko-login +ko-login: ko + @$(KO) login $(REGISTRY) --username $(REGISTRY_USERNAME) --password $(REGISTRY_PASSWORD) + +.PHONY: ko-publish-controller +ko-publish-controller: ko-login + @echo Publishing Controller $(FULL_IMG) - $(KO_TAGS) >&2 + @LD_FLAGS=$(LD_FLAGS) KOCACHE=$(KOCACHE) KO_DOCKER_REPO=$(FULL_IMG) \ + $(KO) build ./cmd/ --bare --tags=$(KO_TAGS) --push=true + +.PHONY: ko-publish-all +ko-publish-all: ko-publish-controller + +#################### +# -- Helm +#################### + +# Helm +SRC_ROOT = $(shell git rev-parse --show-toplevel) + +helm-controller-version: + $(eval VERSION := $(shell grep 'appVersion:' charts/cortex-tenant/Chart.yaml | awk '{print $$2}')) + $(eval KO_TAGS := $(shell grep 'appVersion:' charts/cortex-tenant/Chart.yaml | awk '{print $$2}')) + + +helm-docs: helm-doc + $(HELM_DOCS) --chart-search-root ./charts + +helm-lint: ct + @$(CT) lint --config .github/configs/ct.yaml --validate-yaml=false --all --debug + +helm-schema: helm-plugin-schema + cd charts/cortex-tenant && $(HELM) schema -output values.schema.json + +helm-test: kind ct + @$(KIND) create cluster --wait=60s --name $(KIND_K8S_NAME) --image=kindest/node:$(KIND_K8S_VERSION) + @$(MAKE) e2e-install-distro + @$(MAKE) helm-test-exec + @$(KIND) delete cluster --name $(KIND_K8S_NAME) + +helm-test-exec: ct helm-controller-version ko-build-all + @$(KIND) load docker-image --name cortex-tenant $(FULL_IMG):$(VERSION) + @$(CT) install --config $(SRC_ROOT)/.github/configs/ct.yaml --all --debug + +docker: + @hash docker 2>/dev/null || {\ + echo "You need docker" &&\ + exit 1;\ + } + +#################### +# -- Install E2E Tools +#################### +e2e: e2e-build e2e-exec e2e-destroy + +e2e-build: kind + $(KIND) create cluster --wait=60s --name $(KIND_K8S_NAME) --config ./e2e/kind.yaml --image=kindest/node:$(KIND_K8S_VERSION) + $(MAKE) e2e-install + +e2e-exec: ginkgo + $(GINKGO) -r -vv ./e2e + +e2e-destroy: kind + $(KIND) delete cluster --name $(KIND_K8S_NAME) + +e2e-install: e2e-install-distro e2e-install-addon + +.PHONY: e2e-install +e2e-install-addon: e2e-load-image + helm upgrade \ + --dependency-update \ + --debug \ + --install \ + --namespace cortex-tenant \ + --create-namespace \ + --set 'image.pullPolicy=Never' \ + --set "image.tag=$(VERSION)" \ + --set args.logLevel=10 \ + cortex-tenant \ + ./charts/cortex-tenant + +e2e-install-distro: + @$(KUBECTL) kustomize e2e/objects/flux/ | kubectl apply -f - + @$(KUBECTL) kustomize e2e/objects/distro/ | kubectl apply -f - + @$(MAKE) wait-for-helmreleases + +.PHONY: e2e-load-image +e2e-load-image: ko-build-all + kind load docker-image --name $(KIND_K8S_NAME) $(FULL_IMG):$(VERSION) + +wait-for-helmreleases: + @ echo "Waiting for all HelmReleases to have observedGeneration >= 0..." + @while [ "$$($(KUBECTL) get helmrelease -A -o jsonpath='{range .items[?(@.status.observedGeneration<0)]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' | wc -l)" -ne 0 ]; do \ + sleep 5; \ + done + +##@ Build Dependencies + +## Location to install dependencies to +LOCALBIN ?= $(shell pwd)/bin +$(LOCALBIN): + mkdir -p $(LOCALBIN) + +#################### +# -- Helm Plugins +#################### + +HELM_SCHEMA_VERSION := "" +helm-plugin-schema: + @$(HELM) plugin install https://github.com/losisin/helm-values-schema-json.git --version $(HELM_SCHEMA_VERSION) || true + +HELM_DOCS := $(LOCALBIN)/helm-docs +HELM_DOCS_VERSION := v1.14.1 +HELM_DOCS_LOOKUP := norwoodj/helm-docs +helm-doc: + @test -s $(HELM_DOCS) || \ + $(call go-install-tool,$(HELM_DOCS),github.com/$(HELM_DOCS_LOOKUP)/cmd/helm-docs@$(HELM_DOCS_VERSION)) + +#################### +# -- Tools +#################### +GINKGO := $(LOCALBIN)/ginkgo +ginkgo: + $(call go-install-tool,$(GINKGO),github.com/onsi/ginkgo/v2/ginkgo) + +CT := $(LOCALBIN)/ct +CT_VERSION := v3.12.0 +CT_LOOKUP := helm/chart-testing +ct: + @test -s $(CT) && $(CT) version | grep -q $(CT_VERSION) || \ + $(call go-install-tool,$(CT),github.com/$(CT_LOOKUP)/v3/ct@$(CT_VERSION)) + +KIND := $(LOCALBIN)/kind +KIND_VERSION := v0.27.0 +KIND_LOOKUP := kubernetes-sigs/kind +kind: + @test -s $(KIND) && $(KIND) --version | grep -q $(KIND_VERSION) || \ + $(call go-install-tool,$(KIND),sigs.k8s.io/kind@$(KIND_VERSION)) + +KO := $(LOCALBIN)/ko +KO_VERSION := v0.17.1 +KO_LOOKUP := google/ko +ko: + @test -s $(KO) && $(KO) -h | grep -q $(KO_VERSION) || \ + $(call go-install-tool,$(KO),github.com/$(KO_LOOKUP)@$(KO_VERSION)) + +GOLANGCI_LINT := $(LOCALBIN)/golangci-lint +GOLANGCI_LINT_VERSION := v1.64.5 +GOLANGCI_LINT_LOOKUP := golangci/golangci-lint +golangci-lint: ## Download golangci-lint locally if necessary. + @test -s $(GOLANGCI_LINT) && $(GOLANGCI_LINT) -h | grep -q $(GOLANGCI_LINT_VERSION) || \ + $(call go-install-tool,$(GOLANGCI_LINT),github.com/$(GOLANGCI_LINT_LOOKUP)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)) + +# go-install-tool will 'go install' any package $2 and install it to $1. +PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) +define go-install-tool +[ -f $(1) ] || { \ + set -e ;\ + GOBIN=$(LOCALBIN) go install $(2) ;\ +} +endef diff --git a/README.md b/README.md new file mode 100644 index 0000000..137f383 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +[!IMPORTANT] +This project is a permanent hard-fork of the origin project. + +# Capsule ❤️ Cortex + +![Capsule Cortex](docs/images/logo.png) + +

+ + GitHub release (latest SemVer) + + + Artifact Hub + + + + +

+ +Prometheus remote write proxy which marks timeseries with a Cortex/Mimir tenant ID based on labels. + +## Overview + +![Architecture](docs/images/capsule-cortex.gif) + +Cortex/Mimir tenants (separate namespaces where metrics are stored to and queried from) are identified by `X-Scope-OrgID` HTTP header on both writes and queries. + +This software solves the problem using the following logic: + +- Receive Prometheus remote write +- Search each timeseries for a specific label name and extract a tenant ID from its value. + If the label wasn't found then it can fall back to a configurable default ID. + If none is configured then the write request will be rejected with HTTP code 400 +- Optionally removes this label from the timeseries +- Groups timeseries by tenant +- Issues a number of parallel per-tenant HTTP requests to Cortex/Mimir with the relevant tenant HTTP header (`X-Scope-OrgID` by default) + +## Documentation + +See the [Documentation](docs/README.md) for more information on how to use this addon. + +## Support + +This addon is developed by the community. For enterprise support (production ready setup,tailor-made features) reach out to [Capsule Supporters](https://projectcapsule.dev/support/) diff --git a/charts/cortex-tenant/.helmignore b/charts/cortex-tenant/.helmignore new file mode 100644 index 0000000..4dc2633 --- /dev/null +++ b/charts/cortex-tenant/.helmignore @@ -0,0 +1,26 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ +ci/ +artifacthub-repo.yml +.schema.yaml diff --git a/charts/cortex-tenant/.schema.yaml b/charts/cortex-tenant/.schema.yaml new file mode 100644 index 0000000..3febf19 --- /dev/null +++ b/charts/cortex-tenant/.schema.yaml @@ -0,0 +1,3 @@ +input: + - values.yaml + - ci/test-values.yaml diff --git a/charts/cortex-tenant/Chart.yaml b/charts/cortex-tenant/Chart.yaml new file mode 100644 index 0000000..003a776 --- /dev/null +++ b/charts/cortex-tenant/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: cortex-tenant +description: Capsule Cortex Tenant +type: application +# Note: The version is overwritten by the release workflow. +version: 0.0.0 +# Note: The version is overwritten by the release workflow. +appVersion: 0.0.0 +home: https://github.com/projectcapsule/cortex-tenant +icon: https://github.com/projectcapsule/capsule/raw/main/assets/logo/capsule_small.png +keywords: +- kubernetes +- operator +- multi-tenancy +- multi-tenant +- multitenancy +- multitenant +- cortex +- prometheus +sources: + - https://github.com/projectcapsule/cortex-tenant diff --git a/charts/cortex-tenant/README.md b/charts/cortex-tenant/README.md new file mode 100644 index 0000000..d3fc523 --- /dev/null +++ b/charts/cortex-tenant/README.md @@ -0,0 +1,123 @@ +# Capsule ❤️ Cortex + +![Logo](https://github.com/projectcapsule/cortex-tenant/blob/main/docs/images/logo.png) + +## Installation + +1. Install Helm Chart: + + $ helm install cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-tenant -n monitioring-system + +3. Show the status: + + $ helm status cortex-tenant -n monitioring-system + +4. Upgrade the Chart + + $ helm upgrade cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-tenant --version 0.4.7 + +5. Uninstall the Chart + + $ helm uninstall cortex-tenant -n monitioring-system + +## Values + +The following Values are available for this chart. + +### Global Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| + +### General Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | Set affinity rules | +| args.extraArgs | list | `[]` | A list of extra arguments to add to the capsule-argo-addon | +| args.logLevel | int | `4` | Log Level | +| args.pprof | bool | `false` | Enable Profiling | +| fullnameOverride | string | `""` | | +| image.pullPolicy | string | `"IfNotPresent"` | Set the image pull policy. | +| image.registry | string | `"ghcr.io"` | Set the image registry | +| image.repository | string | `"projectcapsule/cortex-tenant"` | Set the image repository | +| image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| imagePullSecrets | list | `[]` | Configuration for `imagePullSecrets` so that you can use a private images registry. | +| livenessProbe | object | `{"httpGet":{"path":"/healthz","port":10080}}` | Configure the liveness probe using Deployment probe spec | +| nameOverride | string | `""` | | +| nodeSelector | object | `{}` | Set the node selector | +| pdb.enabled | bool | `false` | Specifies whether an hpa should be created. | +| pdb.minAvailable | int | `1` | The number of pods from that set that must still be available after the eviction | +| podAnnotations | object | `{}` | Annotations to add | +| podSecurityContext | object | `{"seccompProfile":{"type":"RuntimeDefault"}}` | Set the securityContext | +| priorityClassName | string | `""` | Set the priority class name of the Capsule pod | +| rbac.enabled | bool | `true` | Enable bootstraping of RBAC resources | +| readinessProbe | object | `{"httpGet":{"path":"/readyz","port":10080}}` | Configure the readiness probe using Deployment probe spec | +| replicaCount | int | `1` | Amount of replicas | +| resources | object | `{"limits":{"cpu":"200m","memory":"128Mi"},"requests":{"cpu":"100m","memory":"128Mi"}}` | Set the resource requests/limits | +| securityContext | object | `{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"readOnlyRootFilesystem":true,"runAsNonRoot":true,"runAsUser":1000}` | Set the securityContext for the container | +| serviceAccount.annotations | object | `{}` | Annotations to add to the service account. | +| serviceAccount.create | bool | `true` | Specifies whether a service account should be created. | +| serviceAccount.name | string | `""` | The name of the service account to use. | +| tolerations | list | `[]` | Set list of tolerations | +| topologySpreadConstraints | list | `[]` | Set topology spread constraints | + +### Config Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| config.backend.auth.password | string | `""` | Password | +| config.backend.auth.username | string | `""` | Username | +| config.backend.url | string | `"http://cortex-distributor.cortex.svc:8080/api/v1/push"` | Where to send the modified requests (Cortex) | +| config.concurrency | int | `1000` | Max number of parallel incoming HTTP requests to handle | +| config.ipv6 | bool | `false` | Whether to enable querying for IPv6 records | +| config.maxConnectionDuration | string | `"0s"` | Maximum duration to keep outgoing connections alive (to Cortex/Mimir) Useful for resetting L4 load-balancer state Use 0 to keep them indefinitely | +| config.maxConnectionsPerHost | int | `64` | This parameter sets the limit for the count of outgoing concurrent connections to Cortex / Mimir. By default it's 64 and if all of these connections are busy you will get errors when pushing from Prometheus. If your `target` is a DNS name that resolves to several IPs then this will be a per-IP limit. | +| config.metadata | bool | `false` | Whether to forward metrics metadata from Prometheus to Cortex Since metadata requests have no timeseries in them - we cannot divide them into tenants So the metadata requests will be sent to the default tenant only, if one is not defined - they will be dropped | +| config.tenant.acceptAll | bool | `false` | Enable if you want all metrics from Prometheus to be accepted with a 204 HTTP code regardless of the response from Cortex. This can lose metrics if Cortex is throwing rejections. | +| config.tenant.default | string | `"cortex-tenant-default"` | Which tenant ID to use if the label is missing in any of the timeseries If this is not set or empty then the write request with missing tenant label will be rejected with HTTP code 400 | +| config.tenant.header | string | `"X-Scope-OrgID"` | To which header to add the tenant ID | +| config.tenant.labelRemove | bool | `false` | Whether to remove the tenant label from the request | +| config.tenant.labels | list | `[]` | List of labels examined for tenant information. If set takes precedent over `label` | +| config.tenant.prefix | string | `""` | Optional hard-coded prefix with delimeter for all tenant values. Delimeters allowed for use: https://grafana.com/docs/mimir/latest/configure/about-tenant-ids/ | +| config.tenant.prefixPreferSource | bool | `false` | If true will use the tenant ID of the inbound request as the prefix of the new tenant id. Will be automatically suffixed with a `-` character. Example: Prometheus forwards metrics with `X-Scope-OrgID: Prom-A` set in the inbound request. This would result in the tenant prefix being set to `Prom-A-`. | +| config.timeout | string | `"10s"` | HTTP request timeout | +| config.timeoutShutdown | string | `"10s"` | Timeout to wait on shutdown to allow load balancers detect that we're going away. During this period after the shutdown command the /alive endpoint will reply with HTTP 503. Set to 0s to disable. | + +### Autoscaling Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| autoscaling.annotations | object | `{}` | Annotations to add to the hpa. | +| autoscaling.behavior | object | `{}` | HPA [behavior](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/) | +| autoscaling.enabled | bool | `false` | Specifies whether an hpa should be created. | +| autoscaling.labels | object | `{}` | Labels to add to the hpa. | +| autoscaling.maxReplicas | int | `3` | Set the maxReplicas for hpa. | +| autoscaling.metrics | list | `[]` | Custom [metrics-objects](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale-walkthrough/#autoscaling-on-multiple-metrics-and-custom-metrics) for capsule-proxy hpa | +| autoscaling.minReplicas | int | `1` | Set the minReplicas for hpa. | +| autoscaling.targetCPUUtilizationPercentage | int | `0` | Set the targetCPUUtilizationPercentage for hpa. | +| autoscaling.targetMemoryUtilizationPercentage | int | `0` | Set the targetMemoryUtilizationPercentage for hpa. | + +### Monitoring Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| monitoring.enabled | bool | `false` | Enable Monitoring of the Operator | +| monitoring.rules.annotations | object | `{}` | Assign additional Annotations | +| monitoring.rules.enabled | bool | `true` | Enable deployment of PrometheusRules | +| monitoring.rules.groups | list | `[{"name":"TranslatorAlerts","rules":[{"alert":"TranslatorNotReady","annotations":{"description":"The Translator {{ $labels.name }} has been in a NotReady state for over 5 minutes.","summary":"Translator {{ $labels.name }} is not ready"},"expr":"cca_translator_condition{status=\"NotReady\"} == 1","for":"5m","labels":{"severity":"warning"}}]}]` | Prometheus Groups for the rule | +| monitoring.rules.labels | object | `{}` | Assign additional labels | +| monitoring.rules.namespace | string | `""` | Install the rules into a different Namespace, as the monitoring stack one (default: the release one) | +| monitoring.serviceMonitor.annotations | object | `{}` | Assign additional Annotations | +| monitoring.serviceMonitor.enabled | bool | `true` | Enable ServiceMonitor | +| monitoring.serviceMonitor.endpoint.interval | string | `"15s"` | Set the scrape interval for the endpoint of the serviceMonitor | +| monitoring.serviceMonitor.endpoint.metricRelabelings | list | `[]` | Set metricRelabelings for the endpoint of the serviceMonitor | +| monitoring.serviceMonitor.endpoint.relabelings | list | `[]` | Set relabelings for the endpoint of the serviceMonitor | +| monitoring.serviceMonitor.endpoint.scrapeTimeout | string | `""` | Set the scrape timeout for the endpoint of the serviceMonitor | +| monitoring.serviceMonitor.jobLabel | string | `"app.kubernetes.io/name"` | Prometheus Joblabel | +| monitoring.serviceMonitor.labels | object | `{}` | Assign additional labels according to Prometheus' serviceMonitorSelector matching labels | +| monitoring.serviceMonitor.matchLabels | object | `{}` | Change matching labels | +| monitoring.serviceMonitor.namespace | string | `""` | Install the ServiceMonitor into a different Namespace, as the monitoring stack one (default: the release one) | +| monitoring.serviceMonitor.serviceAccount.name | string | `""` | | +| monitoring.serviceMonitor.serviceAccount.namespace | string | `""` | | +| monitoring.serviceMonitor.targetLabels | list | `[]` | Set targetLabels for the serviceMonitor | diff --git a/charts/cortex-tenant/README.md.gotmpl b/charts/cortex-tenant/README.md.gotmpl new file mode 100644 index 0000000..64bed11 --- /dev/null +++ b/charts/cortex-tenant/README.md.gotmpl @@ -0,0 +1,77 @@ +# Capsule ❤️ Cortex + +![Logo](https://github.com/projectcapsule/cortex-tenant/blob/main/docs/images/logo.png) + +## Installation + +1. Install Helm Chart: + + $ helm install cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-tenant -n monitioring-system + +3. Show the status: + + $ helm status cortex-tenant -n monitioring-system + +4. Upgrade the Chart + + $ helm upgrade cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-tenant --version 0.4.7 + +5. Uninstall the Chart + + $ helm uninstall cortex-tenant -n monitioring-system + +## Values + +The following Values are available for this chart. + +### Global Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +{{- range .Values }} + {{- if (hasPrefix "global" .Key) }} +| {{ .Key }} | {{ .Type }} | {{ if .Default }}{{ .Default }}{{ else }}{{ .AutoDefault }}{{ end }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | + {{- end }} +{{- end }} + +### General Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +{{- range .Values }} + {{- if not (or (hasPrefix "config" .Key) (hasPrefix "monitoring" .Key) (hasPrefix "autoscaling" .Key) (hasPrefix "global" .Key) (hasPrefix "crds" .Key) (hasPrefix "serviceMonitor" .Key)) }} +| {{ .Key }} | {{ .Type }} | {{ if .Default }}{{ .Default }}{{ else }}{{ .AutoDefault }}{{ end }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | + {{- end }} +{{- end }} + + +### Config Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +{{- range .Values }} + {{- if hasPrefix "config" .Key }} +| {{ .Key }} | {{ .Type }} | {{ if .Default }}{{ .Default }}{{ else }}{{ .AutoDefault }}{{ end }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | + {{- end }} +{{- end }} + + +### Autoscaling Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +{{- range .Values }} + {{- if hasPrefix "autoscaling" .Key }} +| {{ .Key }} | {{ .Type }} | {{ if .Default }}{{ .Default }}{{ else }}{{ .AutoDefault }}{{ end }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | + {{- end }} +{{- end }} + +### Monitoring Parameters + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +{{- range .Values }} + {{- if hasPrefix "monitoring" .Key }} +| {{ .Key }} | {{ .Type }} | {{ if .Default }}{{ .Default }}{{ else }}{{ .AutoDefault }}{{ end }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | + {{- end }} +{{- end }} diff --git a/charts/cortex-tenant/artifacthub-repo.yml b/charts/cortex-tenant/artifacthub-repo.yml new file mode 100644 index 0000000..dfd0775 --- /dev/null +++ b/charts/cortex-tenant/artifacthub-repo.yml @@ -0,0 +1,4 @@ +repositoryID: 18aa422b-a610-4c46-8aca-f5849393c5ff +owners: + - name: capsule-maintainers + email: cncf-capsule-maintainers@lists.cncf.io diff --git a/charts/cortex-tenant/ci/test-values.yaml b/charts/cortex-tenant/ci/test-values.yaml new file mode 100644 index 0000000..edd9aac --- /dev/null +++ b/charts/cortex-tenant/ci/test-values.yaml @@ -0,0 +1,2 @@ +image: + pullPolicy: Never diff --git a/charts/cortex-tenant/templates/_helpers.tpl b/charts/cortex-tenant/templates/_helpers.tpl new file mode 100644 index 0000000..197eda3 --- /dev/null +++ b/charts/cortex-tenant/templates/_helpers.tpl @@ -0,0 +1,90 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "helm.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "helm.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "helm.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "helm.labels" -}} +helm.sh/chart: {{ include "helm.chart" . }} +{{ include "helm.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "helm.selectorLabels" -}} +app.kubernetes.io/name: {{ include "helm.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "helm.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "helm.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* Plugin Config-Name */}} +{{- define "config.name" -}} +{{ default "default" $.Values.config.name }} +{{- end }} + + +{{/* +Determine the Kubernetes version to use for jobsFullyQualifiedDockerImage tag +*/}} +{{- define "helm.jobsTagKubeVersion" -}} +{{- if contains "-eks-" .Capabilities.KubeVersion.GitVersion }} +{{- print "v" .Capabilities.KubeVersion.Major "." (.Capabilities.KubeVersion.Minor | replace "+" "") -}} +{{- else }} +{{- print "v" .Capabilities.KubeVersion.Major "." .Capabilities.KubeVersion.Minor -}} +{{- end }} +{{- end }} + +{{/* +Create the jobs fully-qualified Docker image to use +*/}} +{{- define "helm.jobsFullyQualifiedDockerImage" -}} +{{- if .Values.global.jobs.kubectl.image.tag }} +{{- printf "%s/%s:%s" .Values.global.jobs.kubectl.image.registry .Values.global.jobs.kubectl.image.repository .Values.global.jobs.kubectl.image.tag -}} +{{- else }} +{{- printf "%s/%s:%s" .Values.global.jobs.kubectl.image.registry .Values.global.jobs.kubectl.image.repository (include "helm.jobsTagKubeVersion" .) -}} +{{- end }} +{{- end }} diff --git a/charts/cortex-tenant/templates/configuration.yaml b/charts/cortex-tenant/templates/configuration.yaml new file mode 100644 index 0000000..8e35fda --- /dev/null +++ b/charts/cortex-tenant/templates/configuration.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +data: + cortex-tenant.yml: |- + {{- toYaml .Values.config | nindent 4 }} diff --git a/charts/cortex-tenant/templates/deployment.yaml b/charts/cortex-tenant/templates/deployment.yaml new file mode 100644 index 0000000..a202958 --- /dev/null +++ b/charts/cortex-tenant/templates/deployment.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "helm.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "helm.selectorLabels" . | nindent 8 }} + spec: + volumes: + - configMap: + name: {{ include "helm.fullname" . }} + name: config-file + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "helm.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.registry | trimSuffix "/" }}/{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + args: + - --zap-log-level={{ default 4 .Values.args.logLevel }} + - --enable-pprof={{ .Values.args.pprof }} + - --config=/config/cortex-tenant.yml + {{- with .Values.args.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + env: + - name: CT_LISTEN + value: "0.0.0.0:8080" + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- range .Values.envs }} + - name: "{{ .name }}" + value: "{{ .value }}" + {{- end }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + {{- if .Values.args.pprof }} + - name: pprof + containerPort: 8082 + protocol: TCP + {{- end }} + {{- if $.Values.monitoring.enabled }} + - name: metrics + containerPort: 8081 + protocol: TCP + {{- end }} + - name: healthz + containerPort: 10080 + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12}} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12}} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - mountPath: /config/ + name: config-file + priorityClassName: {{ .Values.priorityClassName }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/cortex-tenant/templates/hpa.yaml b/charts/cortex-tenant/templates/hpa.yaml new file mode 100644 index 0000000..754e469 --- /dev/null +++ b/charts/cortex-tenant/templates/hpa.yaml @@ -0,0 +1,42 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} + {{- if .Values.autoscaling.labels }} + {{- toYaml .Values.autoscaling.labels | nindent 4 }} + {{- end }} + {{- with .Values.autoscaling.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "helm.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.metrics }} + {{- toYaml .Values.autoscaling.metrics | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/cortex-tenant/templates/pdb.yaml b/charts/cortex-tenant/templates/pdb.yaml new file mode 100644 index 0000000..1b3b7a7 --- /dev/null +++ b/charts/cortex-tenant/templates/pdb.yaml @@ -0,0 +1,13 @@ +{{- if .Values.pdb.enabled -}} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.pdb.minAvailable }} + selector: + matchLabels: + {{- include "helm.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/cortex-tenant/templates/rbac.yaml b/charts/cortex-tenant/templates/rbac.yaml new file mode 100644 index 0000000..dd31d9a --- /dev/null +++ b/charts/cortex-tenant/templates/rbac.yaml @@ -0,0 +1,78 @@ +{{- if $.Values.rbac.enabled }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +rules: +- apiGroups: + - "" + resources: + - events + verbs: + - list + - update + - create + - patch +- apiGroups: + - capsule.clastix.io + resources: + - tenants + - tenants/status + verbs: + - "*" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "helm.fullname" . }} +subjects: + - name: {{ include "helm.serviceAccountName" . }} + kind: ServiceAccount + namespace: {{ .Release.Namespace | quote }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} +rules: +- apiGroups: + - "coordination.k8s.io" + resources: + - leases + verbs: + - create + - get + - list + - update + - patch + - watch + - delete + - deletecollection +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "helm.fullname" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} + namespace: {{ .Release.Namespace | quote }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "helm.fullname" . }} +subjects: + - name: {{ include "helm.serviceAccountName" . }} + kind: ServiceAccount + namespace: {{ .Release.Namespace | quote }} +{{- end }} diff --git a/charts/cortex-tenant/templates/rules.yaml b/charts/cortex-tenant/templates/rules.yaml new file mode 100644 index 0000000..324c8db --- /dev/null +++ b/charts/cortex-tenant/templates/rules.yaml @@ -0,0 +1,20 @@ + +{{- if and $.Values.monitoring.enabled $.Values.monitoring.rules.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: {{ include "helm.fullname" . }} + namespace: {{ .Values.monitoring.rules.namespace | default .Release.Namespace }} + labels: + {{- include "helm.labels" . | nindent 4 }} + {{- with .Values.monitoring.rules.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.monitoring.rules.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + groups: + {{- toYaml .Values.monitoring.rules.groups | nindent 4 }} +{{- end }} diff --git a/charts/cortex-tenant/templates/service.yaml b/charts/cortex-tenant/templates/service.yaml new file mode 100644 index 0000000..75e6c69 --- /dev/null +++ b/charts/cortex-tenant/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "helm.fullname" . }}-metrics + labels: + {{- include "helm.labels" . | nindent 4 }} +spec: + type: "ClusterIP" + ports: + - port: 8080 + targetPort: metrics + protocol: TCP + name: metrics + selector: + {{- include "helm.selectorLabels" . | nindent 4 }} diff --git a/charts/cortex-tenant/templates/serviceaccount.yaml b/charts/cortex-tenant/templates/serviceaccount.yaml new file mode 100644 index 0000000..6b1382c --- /dev/null +++ b/charts/cortex-tenant/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "helm.serviceAccountName" . }} + labels: + {{- include "helm.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/cortex-tenant/templates/servicemonitor.yaml b/charts/cortex-tenant/templates/servicemonitor.yaml new file mode 100644 index 0000000..b76aac0 --- /dev/null +++ b/charts/cortex-tenant/templates/servicemonitor.yaml @@ -0,0 +1,46 @@ +{{- if and $.Values.monitoring.enabled $.Values.monitoring.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "helm.fullname" . }}-monitor + namespace: {{ .Values.monitoring.serviceMonitor.namespace | default .Release.Namespace }} + labels: + {{- include "helm.labels" . | nindent 4 }} + {{- with .Values.monitoring.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.monitoring.serviceMonitor.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + endpoints: + {{- with .Values.monitoring.serviceMonitor.endpoint }} + - interval: {{ .interval }} + port: metrics + path: /metrics + {{- with .scrapeTimeout }} + scrapeTimeout: {{ . }} + {{- end }} + {{- with .metricRelabelings }} + metricRelabelings: {{- toYaml . | nindent 6 }} + {{- end }} + {{- with .relabelings }} + relabelings: {{- toYaml . | nindent 6 }} + {{- end }} + {{- end }} + jobLabel: {{ .Values.monitoring.serviceMonitor.jobLabel }} + {{- with .Values.monitoring.serviceMonitor.targetLabels }} + targetLabels: {{- toYaml . | nindent 4 }} + {{- end }} + selector: + matchLabels: + {{- if .Values.monitoring.serviceMonitor.matchLabels }} + {{- toYaml .Values.monitoring.serviceMonitor.matchLabels | nindent 6 }} + {{- else }} + {{- include "helm.selectorLabels" . | nindent 6 }} + {{- end }} + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} +{{- end }} diff --git a/charts/cortex-tenant/values.schema.json b/charts/cortex-tenant/values.schema.json new file mode 100644 index 0000000..1e8fb23 --- /dev/null +++ b/charts/cortex-tenant/values.schema.json @@ -0,0 +1,441 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "affinity": { + "properties": {}, + "type": "object" + }, + "args": { + "properties": { + "extraArgs": { + "type": "array" + }, + "logLevel": { + "type": "integer" + }, + "pprof": { + "type": "boolean" + } + }, + "type": "object" + }, + "autoscaling": { + "properties": { + "annotations": { + "properties": {}, + "type": "object" + }, + "behavior": { + "properties": {}, + "type": "object" + }, + "enabled": { + "type": "boolean" + }, + "labels": { + "properties": {}, + "type": "object" + }, + "maxReplicas": { + "type": "integer" + }, + "metrics": { + "type": "array" + }, + "minReplicas": { + "type": "integer" + }, + "targetCPUUtilizationPercentage": { + "type": "integer" + }, + "targetMemoryUtilizationPercentage": { + "type": "integer" + } + }, + "type": "object" + }, + "config": { + "properties": { + "backend": { + "properties": { + "auth": { + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object" + }, + "url": { + "type": "string" + } + }, + "type": "object" + }, + "concurrency": { + "type": "integer" + }, + "ipv6": { + "type": "boolean" + }, + "maxConnectionDuration": { + "type": "string" + }, + "maxConnectionsPerHost": { + "type": "integer" + }, + "metadata": { + "type": "boolean" + }, + "tenant": { + "properties": { + "acceptAll": { + "type": "boolean" + }, + "default": { + "type": "string" + }, + "header": { + "type": "string" + }, + "labelRemove": { + "type": "boolean" + }, + "labels": { + "type": "array" + }, + "prefix": { + "type": "string" + }, + "prefixPreferSource": { + "type": "boolean" + } + }, + "type": "object" + }, + "timeout": { + "type": "string" + }, + "timeoutShutdown": { + "type": "string" + } + }, + "type": "object" + }, + "fullnameOverride": { + "type": "string" + }, + "image": { + "properties": { + "pullPolicy": { + "type": "string" + }, + "registry": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + } + }, + "type": "object" + }, + "imagePullSecrets": { + "type": "array" + }, + "livenessProbe": { + "properties": { + "httpGet": { + "properties": { + "path": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "monitoring": { + "properties": { + "enabled": { + "type": "boolean" + }, + "rules": { + "properties": { + "annotations": { + "properties": {}, + "type": "object" + }, + "enabled": { + "type": "boolean" + }, + "groups": { + "items": { + "properties": { + "name": { + "type": "string" + }, + "rules": { + "items": { + "properties": { + "alert": { + "type": "string" + }, + "annotations": { + "properties": { + "description": { + "type": "string" + }, + "summary": { + "type": "string" + } + }, + "type": "object" + }, + "expr": { + "type": "string" + }, + "for": { + "type": "string" + }, + "labels": { + "properties": { + "severity": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + } + }, + "type": "object" + }, + "type": "array" + }, + "labels": { + "properties": {}, + "type": "object" + }, + "namespace": { + "type": "string" + } + }, + "type": "object" + }, + "serviceMonitor": { + "properties": { + "annotations": { + "properties": {}, + "type": "object" + }, + "enabled": { + "type": "boolean" + }, + "endpoint": { + "properties": { + "interval": { + "type": "string" + }, + "metricRelabelings": { + "type": "array" + }, + "relabelings": { + "type": "array" + }, + "scrapeTimeout": { + "type": "string" + } + }, + "type": "object" + }, + "jobLabel": { + "type": "string" + }, + "labels": { + "properties": {}, + "type": "object" + }, + "matchLabels": { + "properties": {}, + "type": "object" + }, + "namespace": { + "type": "string" + }, + "serviceAccount": { + "properties": { + "name": { + "type": "string" + }, + "namespace": { + "type": "string" + } + }, + "type": "object" + }, + "targetLabels": { + "type": "array" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "nameOverride": { + "type": "string" + }, + "nodeSelector": { + "properties": {}, + "type": "object" + }, + "pdb": { + "properties": { + "enabled": { + "type": "boolean" + }, + "minAvailable": { + "type": "integer" + } + }, + "type": "object" + }, + "podAnnotations": { + "properties": {}, + "type": "object" + }, + "podSecurityContext": { + "properties": { + "seccompProfile": { + "properties": { + "type": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "priorityClassName": { + "type": "string" + }, + "rbac": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "type": "object" + }, + "readinessProbe": { + "properties": { + "httpGet": { + "properties": { + "path": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "replicaCount": { + "type": "integer" + }, + "resources": { + "properties": { + "limits": { + "properties": { + "cpu": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "type": "object" + }, + "requests": { + "properties": { + "cpu": { + "type": "string" + }, + "memory": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "securityContext": { + "properties": { + "allowPrivilegeEscalation": { + "type": "boolean" + }, + "capabilities": { + "properties": { + "drop": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "readOnlyRootFilesystem": { + "type": "boolean" + }, + "runAsNonRoot": { + "type": "boolean" + }, + "runAsUser": { + "type": "integer" + } + }, + "type": "object" + }, + "serviceAccount": { + "properties": { + "annotations": { + "properties": {}, + "type": "object" + }, + "create": { + "type": "boolean" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "tolerations": { + "type": "array" + }, + "topologySpreadConstraints": { + "type": "array" + } + }, + "type": "object" +} diff --git a/charts/cortex-tenant/values.yaml b/charts/cortex-tenant/values.yaml new file mode 100644 index 0000000..8fcc780 --- /dev/null +++ b/charts/cortex-tenant/values.yaml @@ -0,0 +1,260 @@ +# Default values for helm. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. +rbac: + # -- Enable bootstraping of RBAC resources + enabled: true + +nameOverride: "" +fullnameOverride: "" + +# Plugin Configuration +config: + # -- Whether to enable querying for IPv6 records + ipv6: false + # -- HTTP request timeout + timeout: 10s + # -- Timeout to wait on shutdown to allow load balancers detect that we're going away. + # During this period after the shutdown command the /alive endpoint will reply with HTTP 503. + # Set to 0s to disable. + timeoutShutdown: 10s + # -- Max number of parallel incoming HTTP requests to handle + concurrency: 1000 + # -- Whether to forward metrics metadata from Prometheus to Cortex + # Since metadata requests have no timeseries in them - we cannot divide them into tenants + # So the metadata requests will be sent to the default tenant only, if one is not defined - they will be dropped + metadata: false + # -- Maximum duration to keep outgoing connections alive (to Cortex/Mimir) + # Useful for resetting L4 load-balancer state + # Use 0 to keep them indefinitely + maxConnectionDuration: 0s + # -- This parameter sets the limit for the count of outgoing concurrent connections to Cortex / Mimir. + # By default it's 64 and if all of these connections are busy you will get errors when pushing from Prometheus. + # If your `target` is a DNS name that resolves to several IPs then this will be a per-IP limit. + maxConnectionsPerHost: 64 + + backend: + # -- Where to send the modified requests (Cortex) + url: http://cortex-distributor.cortex.svc:8080/api/v1/push + # Authentication (optional) + auth: + # -- Username + username: "" + # -- Password + password: "" + + tenant: + # -- List of labels examined for tenant information. If set takes precedent over `label` + labels: [] + # -- Optional hard-coded prefix with delimeter for all tenant values. + # Delimeters allowed for use: + # https://grafana.com/docs/mimir/latest/configure/about-tenant-ids/ + prefix: "" + # -- If true will use the tenant ID of the inbound request as the prefix of the new tenant id. + # Will be automatically suffixed with a `-` character. + # Example: + # Prometheus forwards metrics with `X-Scope-OrgID: Prom-A` set in the inbound request. + # This would result in the tenant prefix being set to `Prom-A-`. + prefixPreferSource: false + # -- Whether to remove the tenant label from the request + labelRemove: false + # -- To which header to add the tenant ID + header: X-Scope-OrgID + # -- Which tenant ID to use if the label is missing in any of the timeseries + # If this is not set or empty then the write request with missing tenant label + # will be rejected with HTTP code 400 + default: cortex-tenant-default + # -- Enable if you want all metrics from Prometheus to be accepted with a 204 HTTP code + # regardless of the response from Cortex. This can lose metrics if Cortex is + # throwing rejections. + acceptAll: false + +# Arguments for the controller +args: + # -- Enable Profiling + pprof: false + # -- Log Level + logLevel: 4 + # -- A list of extra arguments to add to the capsule-argo-addon + extraArgs: [] + +# -- Amount of replicas +replicaCount: 1 +image: + # -- Set the image registry + registry: ghcr.io + # -- Set the image repository + repository: projectcapsule/cortex-tenant + # -- Set the image pull policy. + pullPolicy: IfNotPresent + # -- Overrides the image tag whose default is the chart appVersion. + tag: "" + +# -- Configuration for `imagePullSecrets` so that you can use a private images registry. +imagePullSecrets: [] + +serviceAccount: + # -- Specifies whether a service account should be created. + create: true + # -- Annotations to add to the service account. + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + # -- The name of the service account to use. + name: "" + +# -- Annotations to add +podAnnotations: {} + +# -- Set the securityContext +podSecurityContext: + seccompProfile: + type: RuntimeDefault + +# -- Set the securityContext for the container +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + +# -- Configure the liveness probe using Deployment probe spec +livenessProbe: + httpGet: + path: /healthz + port: 10080 + +# -- Configure the readiness probe using Deployment probe spec +readinessProbe: + httpGet: + path: /readyz + port: 10080 + +# -- Set the resource requests/limits +resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + +# -- Set the priority class name of the Capsule pod +priorityClassName: '' # system-cluster-critical + +# -- Set the node selector +nodeSelector: {} + +# -- Set list of tolerations +tolerations: [] + +# -- Set affinity rules +affinity: {} + +# -- Set topology spread constraints +topologySpreadConstraints: [] + +# Pod Disruption Budget +pdb: + # -- Specifies whether an hpa should be created. + enabled: false + # -- The number of pods from that set that must still be available after the eviction + minAvailable: 1 + +# HorizontalPodAutoscaler +autoscaling: + # -- Specifies whether an hpa should be created. + enabled: false + # -- Labels to add to the hpa. + labels: {} + # -- Annotations to add to the hpa. + annotations: {} + # -- Set the minReplicas for hpa. + minReplicas: 1 + # -- Set the maxReplicas for hpa. + maxReplicas: 3 + # -- Set the targetCPUUtilizationPercentage for hpa. + targetCPUUtilizationPercentage: 0 + # -- Set the targetMemoryUtilizationPercentage for hpa. + targetMemoryUtilizationPercentage: 0 + # -- Custom [metrics-objects](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale-walkthrough/#autoscaling-on-multiple-metrics-and-custom-metrics) for capsule-proxy hpa + metrics: [] + # - type: Pods + # pods: + # metric: + # name: packets-per-second + # target: + # type: AverageValue + # averageValue: 1k + # -- HPA [behavior](https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/) + behavior: {} + # scaleDown: + # policies: + # - type: Pods + # value: 4 + # periodSeconds: 60 + # - type: Percent + # value: 10 + # periodSeconds: 60 + + +# Monitoring Values +monitoring: + # -- Enable Monitoring of the Operator + enabled: false + # PrometheusRules + rules: + # -- Enable deployment of PrometheusRules + enabled: true + # -- Install the rules into a different Namespace, as the monitoring stack one (default: the release one) + namespace: '' + # -- Assign additional labels + labels: {} + # -- Assign additional Annotations + annotations: {} + # -- Prometheus Groups for the rule + groups: + - name: TranslatorAlerts + rules: + - alert: TranslatorNotReady + expr: cca_translator_condition{status="NotReady"} == 1 + for: 5m + labels: + severity: warning + annotations: + summary: "Translator {{ $labels.name }} is not ready" + description: "The Translator {{ $labels.name }} has been in a NotReady state for over 5 minutes." + + # ServiceMonitor + serviceMonitor: + # -- Enable ServiceMonitor + enabled: true + # -- Install the ServiceMonitor into a different Namespace, as the monitoring stack one (default: the release one) + namespace: '' + # -- Assign additional labels according to Prometheus' serviceMonitorSelector matching labels + labels: {} + # -- Assign additional Annotations + annotations: {} + # -- Change matching labels + matchLabels: {} + # -- Prometheus Joblabel + jobLabel: app.kubernetes.io/name + # -- Set targetLabels for the serviceMonitor + targetLabels: [] + serviceAccount: + # @default -- `capsule-proxy` + name: "" + # @default -- `.Release.Namespace` + namespace: "" + endpoint: + # -- Set the scrape interval for the endpoint of the serviceMonitor + interval: "15s" + # -- Set the scrape timeout for the endpoint of the serviceMonitor + scrapeTimeout: "" + # -- Set metricRelabelings for the endpoint of the serviceMonitor + metricRelabelings: [] + # -- Set relabelings for the endpoint of the serviceMonitor + relabelings: [] diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..aa96bce --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "flag" + "os" + + _ "github.com/KimMachineGun/automemlimit" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + _ "go.uber.org/automaxprocs" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + "github.com/projectcapsule/cortex-tenant/internal/config" + "github.com/projectcapsule/cortex-tenant/internal/controllers" + "github.com/projectcapsule/cortex-tenant/internal/metrics" + "github.com/projectcapsule/cortex-tenant/internal/processor" + "github.com/projectcapsule/cortex-tenant/internal/stores" +) + +var Version string + +var ( + scheme = runtime.NewScheme() + setupLog = ctrl.Log.WithName("setup") +) + +//nolint:wsl +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(capsulev1beta2.AddToScheme(scheme)) + //+kubebuilder:scaffold:scheme +} + +func main() { + var metricsAddr, cfgFile string + + var enablePprof bool + + var probeAddr string + + ctx := ctrl.SetupSignalHandler() + + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8081", "The address the metric endpoint binds to.") + flag.StringVar(&probeAddr, "health-probe-bind-address", ":10080", "The address the probe endpoint binds to.") + flag.BoolVar(&enablePprof, "enable-pprof", false, "Enables Pprof endpoint for profiling (not recommend in production)") + flag.StringVar(&cfgFile, "config", "", "Path to a config file") + + opts := zap.Options{ + Development: true, + } + + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + cfg, err := config.Load(cfgFile) + if err != nil { + setupLog.Error(err, "unable to load config") + os.Exit(1) + } + + setupLog.Info("loaded config", "config", cfg) + + ctrlConfig := ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{BindAddress: metricsAddr}, + HealthProbeBindAddress: probeAddr, + LeaderElection: false, + } + + if enablePprof { + ctrlConfig.PprofBindAddress = ":8082" + } + + setupLog.Info("initializing manager") + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrlConfig) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + directClient, err := client.New(ctrl.GetConfigOrDie(), client.Options{ + Scheme: mgr.GetScheme(), + Mapper: mgr.GetRESTMapper(), + }) + if err != nil { + setupLog.Error(err, "unable to initialize client") + os.Exit(1) + } + + store := stores.NewTenantStore() + metricsRecorder := metrics.MustMakeRecorder() + + tenants := &controllers.TenantController{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + // Log: ctrl.Log.WithName("Store").WithName("Config"), + Metrics: metricsRecorder, + Store: store, + } + + if err = tenants.Init(ctx, directClient); err != nil { + setupLog.Error(err, "unable to initialize settings") + os.Exit(1) + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up health check") + os.Exit(1) + } + + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + setupLog.Error(err, "unable to set up ready check") + os.Exit(1) + } + + proc := processor.NewProcessor(ctrl.Log.WithName("processor"), *cfg, store, metricsRecorder) + if err := mgr.Add(proc); err != nil { + setupLog.Error(err, "unable to add processor to manager") + os.Exit(1) + } + + setupLog.Info("starting manager") + + if err := mgr.Start(ctx); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/commitlint.config.cjs b/commitlint.config.cjs new file mode 100644 index 0000000..9b11f64 --- /dev/null +++ b/commitlint.config.cjs @@ -0,0 +1,16 @@ +const Configuration = { + extends: ['@commitlint/config-conventional'], + plugins: ['commitlint-plugin-function-rules'], + rules: { + 'type-enum': [2, 'always', ['chore', 'ci', 'docs', 'feat', 'test', 'fix', 'sec']], + 'body-max-line-length': [1, 'always', 500], + 'header-max-length': [1, 'always', 200], + 'subject-case': [2, 'always', ['lower-case', 'sentence-case', 'upper-case']], + }, + /* + * Whether commitlint uses the default ignore rules, see the description above. + */ + defaultIgnores: true, +}; + +module.exports = Configuration; diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..3224371 --- /dev/null +++ b/config.yml @@ -0,0 +1,28 @@ +listen: 0.0.0.0:8080 + +backend: + url: http://127.0.0.1:9091/receive + auth: + egress: + username: foo + password: bar + +enable_ipv6: false +max_conns_per_host: 64 + +timeout: 10s +timeout_shutdown: 0s +concurrency: 10 +metadata: false +log_response_errors: true + +tenant: + labels: + - tenant + - other_tenant + prefix: "" + prefix_prefer_source: false + label_remove: true + header: X-Scope-OrgID + default: "" + accept_all: false diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..73a82d3 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,10 @@ +# Documentation + +See the following topics for more information on how to use this addon: + +- [Installation](installation.md) +- [Configuration](configuration.md) +- [Monitoring](monitoring.md) +- [Development](development.md) + +If you notice any issues, please report them in the [GitHub issues](https://github.com/projectcapsule/cortex-tenant/issues/new). We are happy for any contribution. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..ba94c4b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,120 @@ +# Configuration + +The service can be configured by a config file and/or environment variables. Config file may be specified by passing `-config` CLI argument. + +If both are used then the env vars have precedence (i.e. they override values from config). +See below for config file format and corresponding env vars. + +```yaml +# Where to listen for incoming write requests from Prometheus +# env: CT_LISTEN +listen: 0.0.0.0:8080 + +# Profiling API, remove to disable +# env: CT_LISTEN_PPROF +listen_pprof: 0.0.0.0:7008 + +# Where to send the modified requests (Cortex/Mimir) +backend: + url: http://127.0.0.1:9091/receive + # Authentication (optional) + auth: + # Egress HTTP basic auth -> add `Authentication` header to outgoing requests + egress: + # env: CT_AUTH_EGRESS_USERNAME + username: foo + # env: CT_AUTH_EGRESS_PASSWORD + password: bar + +# Whether to enable querying for IPv6 records +# env: CT_ENABLE_IPV6 +enable_ipv6: false + +# This parameter sets the limit for the count of outgoing concurrent connections to Cortex / Mimir. +# By default it's 64 and if all of these connections are busy you will get errors when pushing from Prometheus. +# If your `target` is a DNS name that resolves to several IPs then this will be a per-IP limit. +# env: CT_MAX_CONNS_PER_HOST +max_conns_per_host: 0 + +# HTTP request timeout +# env: CT_TIMEOUT +timeout: 10s + +# Timeout to wait on shutdown to allow load balancers detect that we're going away. +# During this period after the shutdown command the /alive endpoint will reply with HTTP 503. +# Set to 0s to disable. +# env: CT_TIMEOUT_SHUTDOWN +timeout_shutdown: 10s + +# Max number of parallel incoming HTTP requests to handle +# env: CT_CONCURRENCY +concurrency: 10 + +# Whether to forward metrics metadata from Prometheus to Cortex/Mimir +# Since metadata requests have no timeseries in them - we cannot divide them into tenants +# So the metadata requests will be sent to the default tenant only, if one is not defined - they will be dropped +# env: CT_METADATA +metadata: false + +# If true response codes from metrics backend will be logged to stdout. This setting can be used to suppress errors +# which can be quite verbose like 400 code - out-of-order samples or 429 on hitting ingestion limits +# Also, those are already reported by other services like Cortex/Mimir distributors and ingesters +# env: CT_LOG_RESPONSE_ERRORS +log_response_errors: true + +# Maximum duration to keep outgoing connections alive (to Cortex/Mimir) +# Useful for resetting L4 load-balancer state +# Use 0 to keep them indefinitely +# env: CT_MAX_CONN_DURATION +max_connection_duration: 0s + +# Address where metrics are available +# env: CT_LISTEN_METRICS_ADDRESS +listen_metrics_address: 0.0.0.0:9090 + +# If true, then a label with the tenant’s name will be added to the metrics +# env: CT_METRICS_INCLUDE_TENANT +metrics_include_tenant: true + +tenant: + # List of labels examined for tenant information. + # env: CT_TENANT_LABEL_LIST + label_list: + - tenant + - other_tenant + + # Whether to remove the tenant label from the request + # env: CT_TENANT_LABEL_REMOVE + label_remove: true + + # To which header to add the tenant ID + # env: CT_TENANT_HEADER + header: X-Scope-OrgID + + # Which tenant ID to use if the label is missing in any of the timeseries + # If this is not set or empty then the write request with missing tenant label + # will be rejected with HTTP code 400 + # env: CT_TENANT_DEFAULT + default: foobar + + # Enable if you want all metrics from Prometheus to be accepted with a 204 HTTP code + # regardless of the response from upstream. This can lose metrics if Cortex/Mimir is + # throwing rejections. + # env: CT_TENANT_ACCEPT_ALL + accept_all: false + + # Optional prefix to be added to a tenant header before sending it to Cortex/Mimir. + # Make sure to use only allowed characters: + # https://grafana.com/docs/mimir/latest/configure/about-tenant-ids/ + # env: CT_TENANT_PREFIX + prefix: foobar- + + # If true will use the tenant ID of the inbound request as the prefix of the new tenant id. + # Will be automatically suffixed with a `-` character. + # Example: + # Prometheus forwards metrics with `X-Scope-OrgID: Prom-A` set in the inbound request. + # This would result in the tenant prefix being set to `Prom-A-`. + # https://grafana.com/docs/mimir/latest/configure/about-tenant-ids/ + # env: CT_TENANT_PREFIX_PREFER_SOURCE + prefix_prefer_source: false +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..d05003a --- /dev/null +++ b/docs/development.md @@ -0,0 +1,81 @@ +# Development + +Getting started locally is pretty easy. You can execute: + +```shell +make e2e-build +``` + +This installs all required operators an installs the operator within a [KinD Cluster](https://kind.sigs.k8s.io/). The required binaries are also downloaded. + +If you wish to test against a specific Kubernetes version, you can pass that via variable: + +```shell +KIND_K8S_VERSION="v1.31.0" make e2e-build +``` + +When you want to quickly develop, you can scale down the operator within the cluster: + +```shell +kubectl scale deploy capsule-argo-addon --replicas=0 -n capsule-argo-addon +``` + +And then execute the binary: + +```shell +go run cmd/main.go -zap-log-level=10 +``` + +You might need to first export the Kubeconfig for the cluster (If you are using multiple clusters at the same time): + +```shell +bin/kind get kubeconfig --name capsule-arg-addon > /tmp/capsule-argo-addon +export KUBECONFIG="/tmp/capsule-argo-addon" +``` + +## Testing + +When you are done with the development run the following commands. + +For Liniting + +```shell +make golint +``` + +For Unit-Testing + +```shell +make test +``` + +For Unit-Testing (When running Unit-Tests there should not be any `argotranslators`, `tenants` and `appprojects` present): + +```shell +make e2e-exec +``` + +## Helm Chart + +When making changes to the Helm-Chart, Update the documentation by running: + +```shell +make helm-docs +``` + +Linting and Testing the chart: + +```shell +make helm-lint +make helm-test +``` + +## Performance + +Use [PProf](https://book.kubebuilder.io/reference/pprof-tutorial) for profiling: + +```shell +curl -s "http://127.0.0.1:8082/debug/pprof/profile" > ./cpu-profile.out + +go tool pprof -http=:8080 ./cpu-profile.out +``` diff --git a/docs/images/capsule-cortex.gif b/docs/images/capsule-cortex.gif new file mode 100644 index 0000000000000000000000000000000000000000..38255ba1216924aec9e33f71119643280e17e026 GIT binary patch literal 98280 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

0Ua(0P4E? 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

  • -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 literal 0 HcmV?d00001 diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..70f97399495da340a11995d127f52d9c8b4af364 GIT binary patch literal 174384 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

    - - GitHub release (latest SemVer) + + GitHub release (latest SemVer) Artifact Hub - - + +

    diff --git a/charts/cortex-tenant/.helmignore b/charts/cortex-proxy/.helmignore similarity index 100% rename from charts/cortex-tenant/.helmignore rename to charts/cortex-proxy/.helmignore diff --git a/charts/cortex-tenant/.schema.yaml b/charts/cortex-proxy/.schema.yaml similarity index 100% rename from charts/cortex-tenant/.schema.yaml rename to charts/cortex-proxy/.schema.yaml diff --git a/charts/cortex-tenant/Chart.yaml b/charts/cortex-proxy/Chart.yaml similarity index 77% rename from charts/cortex-tenant/Chart.yaml rename to charts/cortex-proxy/Chart.yaml index 003a776..d3d519b 100644 --- a/charts/cortex-tenant/Chart.yaml +++ b/charts/cortex-proxy/Chart.yaml @@ -1,12 +1,12 @@ apiVersion: v2 -name: cortex-tenant +name: cortex-proxy description: Capsule Cortex Tenant type: application # Note: The version is overwritten by the release workflow. version: 0.0.0 # Note: The version is overwritten by the release workflow. appVersion: 0.0.0 -home: https://github.com/projectcapsule/cortex-tenant +home: https://github.com/projectcapsule/cortex-proxy icon: https://github.com/projectcapsule/capsule/raw/main/assets/logo/capsule_small.png keywords: - kubernetes @@ -18,4 +18,4 @@ keywords: - cortex - prometheus sources: - - https://github.com/projectcapsule/cortex-tenant + - https://github.com/projectcapsule/cortex-proxy diff --git a/charts/cortex-tenant/README.md b/charts/cortex-proxy/README.md similarity index 98% rename from charts/cortex-tenant/README.md rename to charts/cortex-proxy/README.md index d3fc523..279c797 100644 --- a/charts/cortex-tenant/README.md +++ b/charts/cortex-proxy/README.md @@ -1,12 +1,12 @@ # Capsule ❤️ Cortex -![Logo](https://github.com/projectcapsule/cortex-tenant/blob/main/docs/images/logo.png) +![Logo](https://github.com/projectcapsule/cortex-proxy/blob/main/docs/images/logo.png) ## Installation 1. Install Helm Chart: - $ helm install cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-tenant -n monitioring-system + $ helm install cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-proxy -n monitioring-system 3. Show the status: @@ -14,7 +14,7 @@ 4. Upgrade the Chart - $ helm upgrade cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-tenant --version 0.4.7 + $ helm upgrade cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-proxy --version 0.4.7 5. Uninstall the Chart diff --git a/charts/cortex-tenant/README.md.gotmpl b/charts/cortex-proxy/README.md.gotmpl similarity index 93% rename from charts/cortex-tenant/README.md.gotmpl rename to charts/cortex-proxy/README.md.gotmpl index 64bed11..bad698e 100644 --- a/charts/cortex-tenant/README.md.gotmpl +++ b/charts/cortex-proxy/README.md.gotmpl @@ -1,12 +1,12 @@ # Capsule ❤️ Cortex -![Logo](https://github.com/projectcapsule/cortex-tenant/blob/main/docs/images/logo.png) +![Logo](https://github.com/projectcapsule/cortex-proxy/blob/main/docs/images/logo.png) ## Installation 1. Install Helm Chart: - $ helm install cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-tenant -n monitioring-system + $ helm install cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-proxy -n monitioring-system 3. Show the status: @@ -14,7 +14,7 @@ 4. Upgrade the Chart - $ helm upgrade cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-tenant --version 0.4.7 + $ helm upgrade cortex-tenant oci://ghcr.io/projectcapsule/charts/cortex-proxy --version 0.4.7 5. Uninstall the Chart diff --git a/charts/cortex-tenant/artifacthub-repo.yml b/charts/cortex-proxy/artifacthub-repo.yml similarity index 100% rename from charts/cortex-tenant/artifacthub-repo.yml rename to charts/cortex-proxy/artifacthub-repo.yml diff --git a/charts/cortex-tenant/ci/test-values.yaml b/charts/cortex-proxy/ci/test-values.yaml similarity index 100% rename from charts/cortex-tenant/ci/test-values.yaml rename to charts/cortex-proxy/ci/test-values.yaml diff --git a/charts/cortex-tenant/templates/_helpers.tpl b/charts/cortex-proxy/templates/_helpers.tpl similarity index 100% rename from charts/cortex-tenant/templates/_helpers.tpl rename to charts/cortex-proxy/templates/_helpers.tpl diff --git a/charts/cortex-tenant/templates/configuration.yaml b/charts/cortex-proxy/templates/configuration.yaml similarity index 100% rename from charts/cortex-tenant/templates/configuration.yaml rename to charts/cortex-proxy/templates/configuration.yaml diff --git a/charts/cortex-tenant/templates/deployment.yaml b/charts/cortex-proxy/templates/deployment.yaml similarity index 100% rename from charts/cortex-tenant/templates/deployment.yaml rename to charts/cortex-proxy/templates/deployment.yaml diff --git a/charts/cortex-tenant/templates/hpa.yaml b/charts/cortex-proxy/templates/hpa.yaml similarity index 100% rename from charts/cortex-tenant/templates/hpa.yaml rename to charts/cortex-proxy/templates/hpa.yaml diff --git a/charts/cortex-tenant/templates/pdb.yaml b/charts/cortex-proxy/templates/pdb.yaml similarity index 100% rename from charts/cortex-tenant/templates/pdb.yaml rename to charts/cortex-proxy/templates/pdb.yaml diff --git a/charts/cortex-tenant/templates/rbac.yaml b/charts/cortex-proxy/templates/rbac.yaml similarity index 100% rename from charts/cortex-tenant/templates/rbac.yaml rename to charts/cortex-proxy/templates/rbac.yaml diff --git a/charts/cortex-tenant/templates/rules.yaml b/charts/cortex-proxy/templates/rules.yaml similarity index 100% rename from charts/cortex-tenant/templates/rules.yaml rename to charts/cortex-proxy/templates/rules.yaml diff --git a/charts/cortex-tenant/templates/service.yaml b/charts/cortex-proxy/templates/service.yaml similarity index 100% rename from charts/cortex-tenant/templates/service.yaml rename to charts/cortex-proxy/templates/service.yaml diff --git a/charts/cortex-tenant/templates/serviceaccount.yaml b/charts/cortex-proxy/templates/serviceaccount.yaml similarity index 100% rename from charts/cortex-tenant/templates/serviceaccount.yaml rename to charts/cortex-proxy/templates/serviceaccount.yaml diff --git a/charts/cortex-tenant/templates/servicemonitor.yaml b/charts/cortex-proxy/templates/servicemonitor.yaml similarity index 100% rename from charts/cortex-tenant/templates/servicemonitor.yaml rename to charts/cortex-proxy/templates/servicemonitor.yaml diff --git a/charts/cortex-tenant/values.schema.json b/charts/cortex-proxy/values.schema.json similarity index 100% rename from charts/cortex-tenant/values.schema.json rename to charts/cortex-proxy/values.schema.json diff --git a/charts/cortex-tenant/values.yaml b/charts/cortex-proxy/values.yaml similarity index 100% rename from charts/cortex-tenant/values.yaml rename to charts/cortex-proxy/values.yaml diff --git a/cmd/main.go b/cmd/main.go index aa96bce..24a8bb1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -6,6 +6,11 @@ import ( _ "github.com/KimMachineGun/automemlimit" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/cortex-proxy/internal/config" + "github.com/projectcapsule/cortex-proxy/internal/controllers" + "github.com/projectcapsule/cortex-proxy/internal/metrics" + "github.com/projectcapsule/cortex-proxy/internal/processor" + "github.com/projectcapsule/cortex-proxy/internal/stores" _ "go.uber.org/automaxprocs" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -16,12 +21,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - - "github.com/projectcapsule/cortex-tenant/internal/config" - "github.com/projectcapsule/cortex-tenant/internal/controllers" - "github.com/projectcapsule/cortex-tenant/internal/metrics" - "github.com/projectcapsule/cortex-tenant/internal/processor" - "github.com/projectcapsule/cortex-tenant/internal/stores" ) var Version string diff --git a/go.mod b/go.mod index 48e2169..6ca471c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/projectcapsule/cortex-tenant +module github.com/projectcapsule/cortex-proxy go 1.23.0 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5f012b2..7491bde 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -7,7 +7,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/projectcapsule/cortex-tenant/internal/config" + "github.com/projectcapsule/cortex-proxy/internal/config" ) var _ = Describe("Config Loading", func() { diff --git a/internal/controllers/reconciler.go b/internal/controllers/reconciler.go index e39c62d..ef0ec3e 100644 --- a/internal/controllers/reconciler.go +++ b/internal/controllers/reconciler.go @@ -6,13 +6,12 @@ import ( "github.com/go-logr/logr" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/cortex-proxy/internal/metrics" + "github.com/projectcapsule/cortex-proxy/internal/stores" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/projectcapsule/cortex-tenant/internal/metrics" - "github.com/projectcapsule/cortex-tenant/internal/stores" ) // CapsuleArgocdReconciler reconciles a CapsuleArgocd object. diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 12a199e..81c9a93 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -18,12 +18,11 @@ import ( "github.com/google/uuid" me "github.com/hashicorp/go-multierror" "github.com/pkg/errors" + "github.com/projectcapsule/cortex-proxy/internal/config" + "github.com/projectcapsule/cortex-proxy/internal/metrics" + "github.com/projectcapsule/cortex-proxy/internal/stores" "github.com/prometheus/prometheus/prompb" fh "github.com/valyala/fasthttp" - - "github.com/projectcapsule/cortex-tenant/internal/config" - "github.com/projectcapsule/cortex-tenant/internal/metrics" - "github.com/projectcapsule/cortex-tenant/internal/stores" ) type result struct { diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 76f33c3..2424a1d 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -15,9 +15,9 @@ import ( fh "github.com/valyala/fasthttp" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/projectcapsule/cortex-tenant/internal/config" - "github.com/projectcapsule/cortex-tenant/internal/metrics" - "github.com/projectcapsule/cortex-tenant/internal/stores" + "github.com/projectcapsule/cortex-proxy/internal/config" + "github.com/projectcapsule/cortex-proxy/internal/metrics" + "github.com/projectcapsule/cortex-proxy/internal/stores" ) var _ = Describe("Processor Forwarding", func() { diff --git a/internal/stores/store_test.go b/internal/stores/store_test.go index 308ea27..22b483d 100644 --- a/internal/stores/store_test.go +++ b/internal/stores/store_test.go @@ -5,7 +5,7 @@ import ( "testing" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/cortex-tenant/internal/stores" + "github.com/projectcapsule/cortex-proxy/internal/stores" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" . "github.com/onsi/gomega" From 2da7ea4677ac77279df86ab18dd8aa4a35cae7f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Sat, 1 Mar 2025 01:32:51 +0100 Subject: [PATCH 3/4] feat(controller): hard fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- .github/workflows/lint.yaml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 954569f..02b2f68 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -11,26 +11,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - manifests: - name: diff - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 - with: - go-version-file: 'go.mod' - - name: Generate manifests - run: | - make manifests - if [[ $(git diff --stat) != '' ]]; then - echo -e '\033[0;31mManifests outdated! (Run make manifests locally and commit)\033[0m ❌' - git diff --color - exit 1 - else - echo -e '\033[0;32mDocumentation up to date\033[0m ✔' - fi yamllint: name: yamllint runs-on: ubuntu-24.04 From ef8a254fc0ae7868aae0b937d9da7cd078750f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Sat, 1 Mar 2025 02:00:18 +0100 Subject: [PATCH 4/4] feat(controller): hard fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- charts/cortex-proxy/README.md | 2 + charts/cortex-proxy/values.schema.json | 4 ++ charts/cortex-proxy/values.yaml | 7 +++- cmd/main.go | 5 ++- config.yml | 26 +++++------- internal/config/config.go | 2 + internal/config/config_test.go | 3 ++ internal/config/selector.go | 31 ++++++++++++++ internal/controllers/reconciler.go | 58 +++++++++++++++++++++----- 9 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 internal/config/selector.go diff --git a/charts/cortex-proxy/README.md b/charts/cortex-proxy/README.md index 279c797..fc78d89 100644 --- a/charts/cortex-proxy/README.md +++ b/charts/cortex-proxy/README.md @@ -66,6 +66,7 @@ The following Values are available for this chart. | Key | Type | Default | Description | |-----|------|---------|-------------| +| config.backend | object | `{"auth":{"password":"","username":""},"url":"http://cortex-distributor.cortex.svc:8080/api/v1/push"}` | Configure the backend to redirect all the requests to | | config.backend.auth.password | string | `""` | Password | | config.backend.auth.username | string | `""` | Username | | config.backend.url | string | `"http://cortex-distributor.cortex.svc:8080/api/v1/push"` | Where to send the modified requests (Cortex) | @@ -74,6 +75,7 @@ The following Values are available for this chart. | config.maxConnectionDuration | string | `"0s"` | Maximum duration to keep outgoing connections alive (to Cortex/Mimir) Useful for resetting L4 load-balancer state Use 0 to keep them indefinitely | | config.maxConnectionsPerHost | int | `64` | This parameter sets the limit for the count of outgoing concurrent connections to Cortex / Mimir. By default it's 64 and if all of these connections are busy you will get errors when pushing from Prometheus. If your `target` is a DNS name that resolves to several IPs then this will be a per-IP limit. | | config.metadata | bool | `false` | Whether to forward metrics metadata from Prometheus to Cortex Since metadata requests have no timeseries in them - we cannot divide them into tenants So the metadata requests will be sent to the default tenant only, if one is not defined - they will be dropped | +| config.selector | object | `{}` | Specify which tenants should be selected for this proxy. Tenants not matching the labels are not considered by the controller. | | config.tenant.acceptAll | bool | `false` | Enable if you want all metrics from Prometheus to be accepted with a 204 HTTP code regardless of the response from Cortex. This can lose metrics if Cortex is throwing rejections. | | config.tenant.default | string | `"cortex-tenant-default"` | Which tenant ID to use if the label is missing in any of the timeseries If this is not set or empty then the write request with missing tenant label will be rejected with HTTP code 400 | | config.tenant.header | string | `"X-Scope-OrgID"` | To which header to add the tenant ID | diff --git a/charts/cortex-proxy/values.schema.json b/charts/cortex-proxy/values.schema.json index 1e8fb23..c564564 100644 --- a/charts/cortex-proxy/values.schema.json +++ b/charts/cortex-proxy/values.schema.json @@ -90,6 +90,10 @@ "metadata": { "type": "boolean" }, + "selector": { + "properties": {}, + "type": "object" + }, "tenant": { "properties": { "acceptAll": { diff --git a/charts/cortex-proxy/values.yaml b/charts/cortex-proxy/values.yaml index 8fcc780..16add5d 100644 --- a/charts/cortex-proxy/values.yaml +++ b/charts/cortex-proxy/values.yaml @@ -32,7 +32,7 @@ config: # By default it's 64 and if all of these connections are busy you will get errors when pushing from Prometheus. # If your `target` is a DNS name that resolves to several IPs then this will be a per-IP limit. maxConnectionsPerHost: 64 - + # -- Configure the backend to redirect all the requests to backend: # -- Where to send the modified requests (Cortex) url: http://cortex-distributor.cortex.svc:8080/api/v1/push @@ -42,7 +42,10 @@ config: username: "" # -- Password password: "" - + # -- Specify which tenants should be selected for this proxy. + # Tenants not matching the labels are not considered by the controller. + selector: {} + # Tenant Properties tenant: # -- List of labels examined for tenant information. If set takes precedent over `label` labels: [] diff --git a/cmd/main.go b/cmd/main.go index 24a8bb1..dd6a9cc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -100,8 +100,9 @@ func main() { metricsRecorder := metrics.MustMakeRecorder() tenants := &controllers.TenantController{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Selector: cfg.Selector.Selector(), // Log: ctrl.Log.WithName("Store").WithName("Config"), Metrics: metricsRecorder, Store: store, diff --git a/config.yml b/config.yml index 3224371..a1dffc4 100644 --- a/config.yml +++ b/config.yml @@ -1,28 +1,24 @@ -listen: 0.0.0.0:8080 - backend: url: http://127.0.0.1:9091/receive auth: - egress: - username: foo - password: bar - -enable_ipv6: false -max_conns_per_host: 64 - + username: foo + password: bar +# selector: +# matchLabels: +# test: me +ipv6: false +maxConnectionsPerHost: 64 timeout: 10s -timeout_shutdown: 0s +timeoutShutdown: 0s concurrency: 10 metadata: false -log_response_errors: true - tenant: labels: - tenant - other_tenant prefix: "" - prefix_prefer_source: false - label_remove: true + prefixPreferSource: false + labelRemove: true header: X-Scope-OrgID default: "" - accept_all: false + acceptAll: false diff --git a/internal/config/config.go b/internal/config/config.go index ecc8728..5ce136e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,8 @@ type Config struct { EnableIPv6 bool `yaml:"ipv6"` + Selector LabelSelector `yaml:"selector,omitempty"` + Timeout time.Duration `yaml:"timeout"` TimeoutShutdown time.Duration `yaml:"timeoutShutdown"` Concurrency int `yaml:"concurrency"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7491bde..de841b3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -35,6 +35,9 @@ timeout: 7s timeoutShutdown: 2s maxConnectionDuration: 10s maxConnectionsPerHost: 128 +selector: + matchLabels: + special: "tenants" ` tmpFile, err := os.CreateTemp("", "config-test-*.yaml") Expect(err).NotTo(HaveOccurred()) diff --git a/internal/config/selector.go b/internal/config/selector.go new file mode 100644 index 0000000..0c598eb --- /dev/null +++ b/internal/config/selector.go @@ -0,0 +1,31 @@ +package config + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type LabelSelector struct { + MatchLabels map[string]string `yaml:"matchLabels,omitempty"` + MatchExpressions []LabelSelectorRequirement `yaml:"matchExpressions,omitempty"` +} + +type LabelSelectorRequirement struct { + Key string `yaml:"key"` + Operator string `yaml:"operator"` + Values []string `yaml:"values,omitempty"` +} + +func (l *LabelSelector) Selector() *metav1.LabelSelector { + r := &metav1.LabelSelector{ + MatchLabels: l.MatchLabels, + } + for _, req := range l.MatchExpressions { + r.MatchExpressions = append(r.MatchExpressions, metav1.LabelSelectorRequirement{ + Key: req.Key, + Operator: metav1.LabelSelectorOperator(req.Operator), + Values: req.Values, + }) + } + + return r +} diff --git a/internal/controllers/reconciler.go b/internal/controllers/reconciler.go index ef0ec3e..e8aac3d 100644 --- a/internal/controllers/reconciler.go +++ b/internal/controllers/reconciler.go @@ -9,24 +9,50 @@ import ( "github.com/projectcapsule/cortex-proxy/internal/metrics" "github.com/projectcapsule/cortex-proxy/internal/stores" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/predicate" ) -// CapsuleArgocdReconciler reconciles a CapsuleArgocd object. type TenantController struct { client.Client - Metrics *metrics.Recorder - Scheme *runtime.Scheme - Store *stores.TenantStore - Log logr.Logger + Metrics *metrics.Recorder + Scheme *runtime.Scheme + Store *stores.TenantStore + Log logr.Logger + Selector *metav1.LabelSelector } func (r *TenantController) SetupWithManager(mgr ctrl.Manager) error { - return ctrl.NewControllerManagedBy(mgr). - For(&capsulev1beta2.Tenant{}). - Complete(r) + builder := ctrl.NewControllerManagedBy(mgr).For(&capsulev1beta2.Tenant{}) + + // If a selector is provided, add an event filter so that only matching tenants trigger reconcile. + if r.Selector != nil { + selector, err := metav1.LabelSelectorAsSelector(r.Selector) + if err != nil { + return fmt.Errorf("invalid label selector: %w", err) + } + + builder = builder.WithEventFilter(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + return selector.Matches(labels.Set(e.Object.GetLabels())) + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return selector.Matches(labels.Set(e.ObjectNew.GetLabels())) + }, + DeleteFunc: func(e event.DeleteEvent) bool { + return selector.Matches(labels.Set(e.Object.GetLabels())) + }, + GenericFunc: func(e event.GenericEvent) bool { + return selector.Matches(labels.Set(e.Object.GetLabels())) + }, + }) + } + + return builder.Complete(r) } func (r *TenantController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -48,10 +74,22 @@ func (r *TenantController) Reconcile(ctx context.Context, req ctrl.Request) (ctr } // First execttion of the controller to load the settings (without manager cache). -func (r *TenantController) Init(ctx context.Context, client client.Client) (err error) { +func (r *TenantController) Init(ctx context.Context, c client.Client) (err error) { tnts := &capsulev1beta2.TenantList{} - if err := client.List(ctx, tnts); err != nil { + var opts []client.ListOption + + // If a selector is provided, add it as a list option. + if r.Selector != nil { + selector, err := metav1.LabelSelectorAsSelector(r.Selector) + if err != nil { + return fmt.Errorf("invalid label selector: %w", err) + } + + opts = append(opts, client.MatchingLabelsSelector{Selector: selector}) + } + + if err := c.List(ctx, tnts, opts...); err != nil { return fmt.Errorf("could not load tenants: %w", err) }

    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 literal 0 HcmV?d00001 diff --git a/docs/monitoring.md b/docs/monitoring.md new file mode 100644 index 0000000..ed36132 --- /dev/null +++ b/docs/monitoring.md @@ -0,0 +1,26 @@ +# Monitoring + +Via the `/metrics` endpoint and the dedicated port you can scrape Prometheus Metrics. Amongst the standard [Kubebuilder Metrics](https://book-v1.book.kubebuilder.io/beyond_basics/controller_metrics) we provide metrics, which show the current state of translators or tenants. This way you can always be informed, when something is not working as expected. Our custom metrics are prefixed with `cortex_`: + +```shell +curl -s http://localhost:8080/metrics | grep "cortex_" + +... + +# HELP cca_tenant_condition The current condition status of a Tenant. +# TYPE cca_tenant_condition gauge +cca_tenant_condition{name="oil",status="NotReady"} 0 +cca_tenant_condition{name="oil",status="Ready"} 1 +cca_tenant_condition{name="solar",status="NotReady"} 1 +cca_tenant_condition{name="solar",status="Ready"} 0 +cca_tenant_condition{name="wind",status="NotReady"} 0 +cca_tenant_condition{name="wind",status="Ready"} 1 +# HELP cca_translator_condition The current condition status of a Translator. +# TYPE cca_translator_condition gauge +cca_translator_condition{name="default-onboarding",status="NotReady"} 1 +cca_translator_condition{name="default-onboarding",status="Ready"} 0 +cca_translator_condition{name="dev-onboarding",status="NotReady"} 1 +cca_translator_condition{name="dev-onboarding",status="Ready"} 0 +``` + +The Helm-Chart comes with a [ServiceMonitor](https://github.com/prometheus-operator/prometheus-operator/blob/main/Documentation/api.md#servicemonitor) and [PrometheusRules](https://github.com/prometheus-operator/prometheus-operator/blob/main/Documentation/api.md#monitoring.coreos.com/v1.PrometheusRule) diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..3961002 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,19 @@ +# Architecture + +![Architecture](architecture.svg) + +## Overview + +Cortex/Mimir tenants (separate namespaces where metrics are stored to and queried from) are identified by `X-Scope-OrgID` HTTP header on both writes and queries. + +~~Problem is that Prometheus can't be configured to send this header~~ Actually in some recent version (year 2021 onwards) this functionality was added, but the tenant is the same for all jobs. This makes it impossible to use a single Prometheus (or an HA pair) to write to multiple tenants. + +This software solves the problem using the following logic: + +- Receive Prometheus remote write +- Search each timeseries for a specific label name and extract a tenant ID from its value. + If the label wasn't found then it can fall back to a configurable default ID. + If none is configured then the write request will be rejected with HTTP code 400 +- Optionally removes this label from the timeseries +- Groups timeseries by tenant +- Issues a number of parallel per-tenant HTTP requests to Cortex/Mimir with the relevant tenant HTTP header (`X-Scope-OrgID` by default) diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go new file mode 100644 index 0000000..474a80e --- /dev/null +++ b/e2e/e2e_suite_test.go @@ -0,0 +1,14 @@ +//nolint:all +package e2e_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestE2e(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "E2e Suite") +} diff --git a/e2e/kind.yaml b/e2e/kind.yaml new file mode 100644 index 0000000..398bbcb --- /dev/null +++ b/e2e/kind.yaml @@ -0,0 +1,7 @@ +apiVersion: kind.x-k8s.io/v1alpha4 +networking: + apiServerAddress: "127.0.0.1" + apiServerPort: 6443 +kind: Cluster +nodes: + - role: control-plane diff --git a/e2e/objects/distro/capsule.flux.yaml b/e2e/objects/distro/capsule.flux.yaml new file mode 100644 index 0000000..7211c2d --- /dev/null +++ b/e2e/objects/distro/capsule.flux.yaml @@ -0,0 +1,50 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: projectcapsule +spec: + interval: 30s + url: https://projectcapsule.github.io/charts +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: capsule +spec: + serviceAccountName: kustomize-controller + interval: 30s + targetNamespace: capsule-system + releaseName: "capsule" + chart: + spec: + chart: capsule + version: "0.7.4" + sourceRef: + kind: HelmRepository + name: projectcapsule + interval: 24h + install: + createNamespace: true + remediation: + retries: -1 + upgrade: + remediation: + remediateLastFailure: true + driftDetection: + mode: enabled + values: + crds: + install: true + tls: + # -- Start the true controller that injects the CA into mutating and validating webhooks, and CRD as well. + enableController: false + # -- When cert-manager is disabled, Capsule will generate the TLS certificate for webhook and CRDs conversion. + create: false + certManager: + generateCertificates: true + manager: + options: + capsuleUserGroups: + - "projectcapsule.dev" + forceTenantPrefix: false diff --git a/e2e/objects/distro/cert-manager.flux.yaml b/e2e/objects/distro/cert-manager.flux.yaml new file mode 100644 index 0000000..8899d7b --- /dev/null +++ b/e2e/objects/distro/cert-manager.flux.yaml @@ -0,0 +1,38 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: cert-manager + namespace: flux-system +spec: + interval: 30s + url: https://charts.jetstack.io +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: cert-manager +spec: + serviceAccountName: kustomize-controller + interval: 30s + releaseName: "cert-manager" + targetNamespace: "cert-manager" + chart: + spec: + chart: cert-manager + version: "1.15.3" + sourceRef: + kind: HelmRepository + name: cert-manager + interval: 24h + install: + createNamespace: true + upgrade: + remediation: + remediateLastFailure: true + retries: -1 + driftDetection: + mode: enabled + values: + crds: + enabled: true diff --git a/e2e/objects/distro/kustomization.yaml b/e2e/objects/distro/kustomization.yaml new file mode 100644 index 0000000..2b9b0a1 --- /dev/null +++ b/e2e/objects/distro/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: flux-system +resources: + - cert-manager.flux.yaml + - capsule.flux.yaml + - metrics.flux.yaml diff --git a/e2e/objects/distro/metrics.flux.yaml b/e2e/objects/distro/metrics.flux.yaml new file mode 100644 index 0000000..50409c8 --- /dev/null +++ b/e2e/objects/distro/metrics.flux.yaml @@ -0,0 +1,38 @@ +--- +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: metrics-server +spec: + interval: 30s + url: https://kubernetes-sigs.github.io/metrics-server/ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: metrics-server +spec: + serviceAccountName: kustomize-controller + interval: 1m + releaseName: "metrics-server" + targetNamespace: "kube-system" + chart: + spec: + chart: metrics-server + version: "3.12.2" + sourceRef: + kind: HelmRepository + name: metrics-server + interval: 24h + install: + createNamespace: false + remediation: + retries: -1 + upgrade: + remediation: + remediateLastFailure: true + driftDetection: + mode: enabled + values: + args: + - --kubelet-insecure-tls diff --git a/e2e/objects/flux/kustomization.yaml b/e2e/objects/flux/kustomization.yaml new file mode 100644 index 0000000..8a8c244 --- /dev/null +++ b/e2e/objects/flux/kustomization.yaml @@ -0,0 +1,33 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - https://github.com/fluxcd/flux2/releases/download/v2.4.0/install.yaml +patches: + - patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --no-cross-namespace-refs=true + target: + kind: Deployment + name: "(kustomize-controller|helm-controller|notification-controller|image-reflector-controller|image-automation-controller)" + - patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --no-remote-bases=true + target: + kind: Deployment + name: "kustomize-controller" + - patch: | + - op: add + path: /spec/template/spec/containers/0/args/- + value: --default-service-account=default + target: + kind: Deployment + name: "(kustomize-controller|helm-controller)" + - patch: | + - op: replace + path: /spec/replicas + value: 0 + target: + kind: Deployment + name: "(notification-controller|image-reflector-controller|image-automation-controller)" diff --git a/e2e/suite_client_test.go b/e2e/suite_client_test.go new file mode 100644 index 0000000..ea37653 --- /dev/null +++ b/e2e/suite_client_test.go @@ -0,0 +1,60 @@ +//nolint:all +package e2e_test + +import ( + "context" + "time" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type e2eClient struct { + client.Client +} + +func (e *e2eClient) sleep() { + time.Sleep(250 * time.Millisecond) +} + +func (e *e2eClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + defer e.sleep() + + return e.Client.Get(ctx, key, obj, opts...) +} + +func (e *e2eClient) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + defer e.sleep() + + return e.Client.List(ctx, list, opts...) +} + +func (e *e2eClient) Create(ctx context.Context, obj client.Object, opts ...client.CreateOption) error { + defer e.sleep() + obj.SetResourceVersion("") + + return e.Client.Create(ctx, obj, opts...) +} + +func (e *e2eClient) Delete(ctx context.Context, obj client.Object, opts ...client.DeleteOption) error { + defer e.sleep() + + return e.Client.Delete(ctx, obj, opts...) +} + +func (e *e2eClient) Update(ctx context.Context, obj client.Object, opts ...client.UpdateOption) error { + defer e.sleep() + + return e.Client.Update(ctx, obj, opts...) +} + +func (e *e2eClient) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + defer e.sleep() + + return e.Client.Patch(ctx, obj, patch, opts...) +} + +func (e *e2eClient) DeleteAllOf(ctx context.Context, obj client.Object, opts ...client.DeleteAllOfOption) error { + defer e.sleep() + + return e.Client.DeleteAllOf(ctx, obj, opts...) +} diff --git a/e2e/suite_test.go b/e2e/suite_test.go new file mode 100644 index 0000000..3f8752f --- /dev/null +++ b/e2e/suite_test.go @@ -0,0 +1,52 @@ +//nolint:all +package e2e_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + + "k8s.io/client-go/rest" + "k8s.io/kubectl/pkg/scheme" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment +) + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter))) + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + UseExistingCluster: ptr.To(true), + } + + var err error + cfg, err = testEnv.Start() + Expect(err).ToNot(HaveOccurred()) + Expect(cfg).ToNot(BeNil()) + + Expect(capsulev1beta2.AddToScheme(scheme.Scheme)).NotTo(HaveOccurred()) + + ctrlClient, err := client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).ToNot(HaveOccurred()) + Expect(ctrlClient).ToNot(BeNil()) + + k8sClient = &e2eClient{Client: ctrlClient} + + selector := e2eSelector("") + Expect(CleanTenants(selector)).ToNot(HaveOccurred()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + Expect(testEnv.Stop()).ToNot(HaveOccurred()) +}) diff --git a/e2e/utils_test.go b/e2e/utils_test.go new file mode 100644 index 0000000..63a02d9 --- /dev/null +++ b/e2e/utils_test.go @@ -0,0 +1,117 @@ +//nolint:all +package e2e_test + +import ( + "context" + "fmt" + "reflect" + "time" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/labels" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + defaultTimeoutInterval = 20 * time.Second + defaultPollInterval = time.Second + e2eLabel = "argo.addons.projectcapsule.dev/e2e" + suiteLabel = "e2e.argo.addons.projectcapsule.dev/suite" +) + +func e2eConfigName() string { + return "default" +} + +// Returns labels to identify e2e resources. +func e2eLabels(suite string) (labels map[string]string) { + labels = make(map[string]string) + labels["cortex.projectcapsule.dev/e2e"] = "true" + + if suite != "" { + labels["cortex.projectcapsule.dev/suite"] = suite + } + + return +} + +// Returns a label selector to filter e2e resources. +func e2eSelector(suite string) labels.Selector { + return labels.SelectorFromSet(e2eLabels(suite)) +} + +func CleanTenants(selector labels.Selector) error { + res := &capsulev1beta2.TenantList{} + + listOptions := client.ListOptions{ + LabelSelector: selector, + } + + // List the resources based on the provided label selector + if err := k8sClient.List(context.TODO(), res, &listOptions); err != nil { + return fmt.Errorf("failed to list tenants: %w", err) + } + + for _, app := range res.Items { + if err := k8sClient.Delete(context.TODO(), &app); err != nil { + return fmt.Errorf("failed to delete tenant %s: %w", app.GetName(), err) + } + } + + return nil +} + +func DeepCompare(expected, actual interface{}) (bool, string) { + expVal := reflect.ValueOf(expected) + actVal := reflect.ValueOf(actual) + + // If the kinds differ, they are not equal. + if expVal.Kind() != actVal.Kind() { + return false, fmt.Sprintf("kind mismatch: %v vs %v", expVal.Kind(), actVal.Kind()) + } + + switch expVal.Kind() { + case reflect.Slice, reflect.Array: + // Convert slices to []interface{} for ElementsMatch. + expSlice := make([]interface{}, expVal.Len()) + actSlice := make([]interface{}, actVal.Len()) + for i := 0; i < expVal.Len(); i++ { + expSlice[i] = expVal.Index(i).Interface() + } + for i := 0; i < actVal.Len(); i++ { + actSlice[i] = actVal.Index(i).Interface() + } + // Use a dummy tester to capture error messages. + dummy := &dummyT{} + if !assert.ElementsMatch(dummy, expSlice, actSlice) { + return false, fmt.Sprintf("slice mismatch: %v", dummy.errors) + } + return true, "" + case reflect.Struct: + // Iterate over fields and compare recursively. + for i := 0; i < expVal.NumField(); i++ { + fieldName := expVal.Type().Field(i).Name + ok, msg := DeepCompare(expVal.Field(i).Interface(), actVal.Field(i).Interface()) + if !ok { + return false, fmt.Sprintf("field %s mismatch: %s", fieldName, msg) + } + } + return true, "" + default: + // Fallback to reflect.DeepEqual for other types. + if !reflect.DeepEqual(expected, actual) { + return false, fmt.Sprintf("expected %v but got %v", expected, actual) + } + return true, "" + } +} + +// dummyT implements a minimal TestingT for testify. +type dummyT struct { + errors []string +} + +func (d *dummyT) Errorf(format string, args ...interface{}) { + d.errors = append(d.errors, fmt.Sprintf(format, args...)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..48e2169 --- /dev/null +++ b/go.mod @@ -0,0 +1,97 @@ +module github.com/projectcapsule/cortex-tenant + +go 1.23.0 + +toolchain go1.23.5 + +require ( + github.com/blind-oracle/go-common v1.0.7 + github.com/caarlos0/env/v8 v8.0.0 + github.com/go-logr/logr v1.4.2 + github.com/gogo/protobuf v1.3.2 + github.com/golang/snappy v0.0.4 + github.com/google/uuid v1.6.0 + github.com/hashicorp/go-multierror v1.1.1 + github.com/pkg/errors v0.9.1 + github.com/projectcapsule/capsule v0.7.4 + github.com/prometheus/client_golang v1.20.5 + github.com/prometheus/prometheus v0.300.1 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.10.0 + github.com/valyala/fasthttp v1.58.0 + go.uber.org/automaxprocs v1.6.0 + gopkg.in/yaml.v2 v2.4.0 + k8s.io/apimachinery v0.32.1 + k8s.io/client-go v0.32.1 + sigs.k8s.io/controller-runtime v0.20.2 +) + +require ( + github.com/KimMachineGun/automemlimit v0.7.1 // indirect + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.1 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // 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/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect + github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // 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/onsi/ginkgo/v2 v2.22.2 // indirect + github.com/onsi/gomega v1.36.2 // indirect + github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.61.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/oauth2 v0.24.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/term v0.27.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/time v0.7.0 // indirect + golang.org/x/tools v0.28.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/protobuf v1.36.1 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.32.1 // indirect + k8s.io/apiextensions-apiserver v0.32.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect + k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..db29730 --- /dev/null +++ b/go.sum @@ -0,0 +1,278 @@ +github.com/KimMachineGun/automemlimit v0.7.1 h1:QcG/0iCOLChjfUweIMC3YL5Xy9C3VBeNmCZHrZfJMBw= +github.com/KimMachineGun/automemlimit v0.7.1/go.mod h1:QZxpHaGOQoYvFhv/r4u3U0JTC2ZcOwbSr11UZF46UBM= +github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= +github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blind-oracle/go-common v1.0.7 h1:pDBnhwu7JaJRDNF2PcJEZdscR2rcgPLH2sBGIzlnV/0= +github.com/blind-oracle/go-common v1.0.7/go.mod h1:Sw3z/RG/QEPZ3oabRuwcMjVkVOkeeEhKyRpcimQZoUs= +github.com/caarlos0/env/v8 v8.0.0 h1:POhxHhSpuxrLMIdvTGARuZqR4Jjm8AYmoi/JKlcScs0= +github.com/caarlos0/env/v8 v8.0.0/go.mod h1:7K4wMY9bH0esiXSSHlfHLX5xKGQMnkH5Fk4TDSSSzfo= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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.12.1 h1:PJMDIM/ak7btuL8Ex0iYET9hxM3CI2sjZtzpL63nKAU= +github.com/emicklei/go-restful/v3 v3.12.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= +github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +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-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-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +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/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +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.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.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248= +github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +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/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= +github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= +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.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= +github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= +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/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/projectcapsule/capsule v0.7.4 h1:F0m19is1hFkp6K4aNz6g0ogjSkHYVyw6i22t6iZCBFk= +github.com/projectcapsule/capsule v0.7.4/go.mod h1:/liOW6gKPRh8xzrV3FlnP1dXFzOvDGWXuDiaOoaWBdw= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= +github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/prometheus v0.48.0 h1:yrBloImGQ7je4h8M10ujGh4R6oxYQJQKlMuETwNskGk= +github.com/prometheus/prometheus v0.48.0/go.mod h1:SRw624aMAxTfryAcP8rOjg4S/sHHaetx2lyJJ2nM83g= +github.com/prometheus/prometheus v0.300.1 h1:9KKcTTq80gkzmXW0Et/QCFSrBPgmwiS3Hlcxc6o8KlM= +github.com/prometheus/prometheus v0.300.1/go.mod h1:gtTPY/XVyCdqqnjA3NzDMb0/nc5H9hOu1RMame+gHyM= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/fasthttp v1.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE= +github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw= +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/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +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= +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/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= +golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= +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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.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/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= +golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +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= +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.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= +google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +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/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.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/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.1 h1:Xe1hX/fPW3PXYYv8BlozYqw63ytA92snr96zMW9gWTU= +k8s.io/api v0.31.1/go.mod h1:sbN1g6eY6XVLeqNsZGLnI5FwVseTrZX7Fv3O26rhAaI= +k8s.io/api v0.32.1 h1:f562zw9cy+GvXzXf0CKlVQ7yHJVYzLfL6JAS4kOAaOc= +k8s.io/api v0.32.1/go.mod h1:/Yi/BqkuueW1BgpoePYBRdDYfjPF5sgTr5+YqDZra5k= +k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk= +k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= +k8s.io/apiextensions-apiserver v0.32.1 h1:hjkALhRUeCariC8DiVmb5jj0VjIc1N0DREP32+6UXZw= +k8s.io/apiextensions-apiserver v0.32.1/go.mod h1:sxWIGuGiYov7Io1fAS2X06NjMIk5CbRHc2StSmbaQto= +k8s.io/apimachinery v0.31.1 h1:mhcUBbj7KUjaVhyXILglcVjuS4nYXiwC+KKFBgIVy7U= +k8s.io/apimachinery v0.31.1/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/apimachinery v0.32.1 h1:683ENpaCBjma4CYqsmZyhEzrGz6cjn1MY/X2jB2hkZs= +k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.31.1 h1:f0ugtWSbWpxHR7sjVpQwuvw9a3ZKLXX0u0itkFXufb0= +k8s.io/client-go v0.31.1/go.mod h1:sKI8871MJN2OyeqRlmA4W4KM9KBdBUpDLu/43eGemCg= +k8s.io/client-go v0.32.1 h1:otM0AxdhdBIaQh7l1Q0jQpmo7WOFIk5FFa4bg6YMdUU= +k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg= +k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= +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-20240822171749-76de80e0abd9 h1:y+4z/s0h3R97P/o/098DSjlpyNpHzGirNPlTL+GHdqY= +k8s.io/kube-openapi v0.0.0-20240822171749-76de80e0abd9/go.mod h1:s4yb9FXajAVNRnxSB5Ckpr/oq2LP4mKSMWeZDVppd30= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y= +k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4= +k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= +k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.19.4 h1:SUmheabttt0nx8uJtoII4oIP27BVVvAKFvdvGFwV/Qo= +sigs.k8s.io/controller-runtime v0.19.4/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= +sigs.k8s.io/controller-runtime v0.20.2 h1:/439OZVxoEc02psi1h4QO3bHzTgu49bb347Xp4gW1pc= +sigs.k8s.io/controller-runtime v0.20.2/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +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/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= +sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= +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/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA= +sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +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/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..ecc8728 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,85 @@ +package config + +import ( + "os" + "time" + + "github.com/caarlos0/env/v8" + "github.com/pkg/errors" + fhu "github.com/valyala/fasthttp/fasthttputil" + "gopkg.in/yaml.v2" +) + +type Config struct { + Backend *CortexBackend `yaml:"backend"` + + EnableIPv6 bool `yaml:"ipv6"` + + Timeout time.Duration `yaml:"timeout"` + TimeoutShutdown time.Duration `yaml:"timeoutShutdown"` + Concurrency int `yaml:"concurrency"` + Metadata bool `yaml:"metadata"` + MaxConnDuration time.Duration `yaml:"maxConnectionDuration"` + MaxConnsPerHost int `yaml:"maxConnectionsPerHost"` + + Tenant *TenantConfig `yaml:"tenant"` + + PipeIn *fhu.InmemoryListener + PipeOut *fhu.InmemoryListener +} + +type CortexBackend struct { + URL string `yaml:"url"` + Auth struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + } `yaml:"auth"` +} + +type TenantConfig struct { + Labels []string `yaml:"labels"` + Prefix string `yaml:"prefix"` + PrefixPreferSource bool `yaml:"prefixPreferSource"` + LabelRemove bool `yaml:"labelRemove"` + Header string `yaml:"header"` + Default string `yaml:"default"` + AcceptAll bool `yaml:"acceptAll"` +} + +func Load(file string) (*Config, error) { + cfg := &Config{} + + if file != "" { + y, err := os.ReadFile(file) + if err != nil { + return nil, errors.Wrap(err, "Unable to read config") + } + + if err := yaml.UnmarshalStrict(y, cfg); err != nil { + return nil, errors.Wrap(err, "Unable to parse config") + } + } + + if err := env.Parse(cfg); err != nil { + return nil, errors.Wrap(err, "Unable to parse env vars") + } + + if cfg.Concurrency == 0 { + cfg.Concurrency = 512 + } + + if cfg.Tenant.Header == "" { + cfg.Tenant.Header = "X-Scope-OrgID" + } + + // Default to the Label if list is empty + if len(cfg.Tenant.Labels) == 0 { + cfg.Tenant.Labels = append(cfg.Tenant.Labels, "__tenant__") + } + + if cfg.MaxConnsPerHost == 0 { + cfg.MaxConnsPerHost = 64 + } + + return cfg, nil +} diff --git a/internal/config/config_suite_test.go b/internal/config/config_suite_test.go new file mode 100644 index 0000000..2bf6b78 --- /dev/null +++ b/internal/config/config_suite_test.go @@ -0,0 +1,13 @@ +package config_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestProcessor(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..5f012b2 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,80 @@ +package config_test + +import ( + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/projectcapsule/cortex-tenant/internal/config" +) + +var _ = Describe("Config Loading", func() { + BeforeEach(func() { + // Ensure no interfering env vars. + os.Unsetenv("CT_LISTEN") + os.Unsetenv("CT_TENANT_HEADER") + os.Unsetenv("CT_CONCURRENCY") + // Unset other env variables as needed... + }) + + It("should load and override values from a valid YAML file", func() { + // Create a temporary YAML config file. + yamlContent := ` +backend: + url: "http://backend.example.com" +tenant: + labels: ["mytenant"] + prefix: "pfx-" + prefixPreferSource: true + header: "X-Tenant-Header" + default: "defaulttenant" + acceptAll: true +timeout: 7s +timeoutShutdown: 2s +maxConnectionDuration: 10s +maxConnectionsPerHost: 128 +` + tmpFile, err := os.CreateTemp("", "config-test-*.yaml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(tmpFile.Name()) + + _, err = tmpFile.Write([]byte(yamlContent)) + Expect(err).NotTo(HaveOccurred()) + tmpFile.Close() + + cfg, err := config.Load(tmpFile.Name()) + Expect(err).NotTo(HaveOccurred()) + + // Verify values from YAML. + Expect(cfg.Backend).NotTo(BeNil()) + Expect(cfg.Backend.URL).To(Equal("http://backend.example.com")) + Expect(cfg.Tenant).NotTo(BeNil()) + Expect(cfg.Tenant.Labels).To(Equal([]string{"mytenant"})) + Expect(cfg.Tenant.Prefix).To(Equal("pfx-")) + Expect(cfg.Tenant.PrefixPreferSource).To(BeTrue()) + Expect(cfg.Tenant.Header).To(Equal("X-Tenant-Header")) + Expect(cfg.Tenant.Default).To(Equal("defaulttenant")) + Expect(cfg.Tenant.AcceptAll).To(BeTrue()) + + // Verify duration values. + Expect(cfg.Timeout).To(Equal(7 * time.Second)) + Expect(cfg.TimeoutShutdown).To(Equal(2 * time.Second)) + Expect(cfg.MaxConnsPerHost).To(Equal(128)) + }) + + It("should return an error for invalid YAML", func() { + tmpFile, err := os.CreateTemp("", "config-invalid-*.yaml") + Expect(err).NotTo(HaveOccurred()) + defer os.Remove(tmpFile.Name()) + + // Write invalid YAML. + _, err = tmpFile.Write([]byte("invalid: : yaml:::")) + Expect(err).NotTo(HaveOccurred()) + tmpFile.Close() + + _, err = config.Load(tmpFile.Name()) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/internal/controllers/reconciler.go b/internal/controllers/reconciler.go new file mode 100644 index 0000000..e39c62d --- /dev/null +++ b/internal/controllers/reconciler.go @@ -0,0 +1,75 @@ +package controllers + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/cortex-tenant/internal/metrics" + "github.com/projectcapsule/cortex-tenant/internal/stores" +) + +// CapsuleArgocdReconciler reconciles a CapsuleArgocd object. +type TenantController struct { + client.Client + Metrics *metrics.Recorder + Scheme *runtime.Scheme + Store *stores.TenantStore + Log logr.Logger +} + +func (r *TenantController) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&capsulev1beta2.Tenant{}). + Complete(r) +} + +func (r *TenantController) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + origin := &capsulev1beta2.Tenant{} + if err := r.Get(ctx, req.NamespacedName, origin); err != nil { + r.lifecycle(&capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.Name, + Namespace: req.Namespace, + }, + }) + + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + r.Store.Update(origin) + + return ctrl.Result{}, nil +} + +// First execttion of the controller to load the settings (without manager cache). +func (r *TenantController) Init(ctx context.Context, client client.Client) (err error) { + tnts := &capsulev1beta2.TenantList{} + + if err := client.List(ctx, tnts); err != nil { + return fmt.Errorf("could not load tenants: %w", err) + } + + for _, tnt := range tnts.Items { + r.Store.Update(&tnt) + } + + return +} + +func (r *TenantController) lifecycle(tenant *capsulev1beta2.Tenant) { + r.Store.Delete(&capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: tenant.Name, + Namespace: tenant.Namespace, + }, + }) + + r.Metrics.DeleteMetricsForTenant(tenant) +} diff --git a/internal/metrics/recorder.go b/internal/metrics/recorder.go new file mode 100644 index 0000000..4165d07 --- /dev/null +++ b/internal/metrics/recorder.go @@ -0,0 +1,96 @@ +package metrics + +import ( + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" +) + +type Recorder struct { + MetricTimeseriesBatchesReceived prometheus.Counter + MetricTimeseriesBatchesReceivedBytes prometheus.Histogram + MetricTimeseriesReceived *prometheus.CounterVec + MetricTimeseriesRequestDurationSeconds *prometheus.HistogramVec + MetricTimeseriesRequestErrors *prometheus.CounterVec + MetricTimeseriesRequests *prometheus.CounterVec +} + +func MustMakeRecorder() *Recorder { + metricsRecorder := NewRecorder() + crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...) + + return metricsRecorder +} + +func NewRecorder() *Recorder { + return &Recorder{ + MetricTimeseriesBatchesReceived: prometheus.NewCounter( + prometheus.CounterOpts{ + Namespace: "cortex_tenant", + Name: "timeseries_batches_received_total", + Help: "The total number of batches received.", + }, + ), + MetricTimeseriesBatchesReceivedBytes: prometheus.NewHistogram( + prometheus.HistogramOpts{ + Namespace: "cortex_tenant", + Name: "timeseries_batches_received_bytes", + Help: "Size in bytes of timeseries batches received.", + Buckets: []float64{0.5, 1, 10, 25, 100, 250, 500, 1000, 5000, 10000, 30000, 300000, 600000, 1800000, 3600000}, + }, + ), + MetricTimeseriesReceived: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "cortex_tenant", + Name: "timeseries_received_total", + Help: "The total number of timeseries received.", + }, + []string{"tenant"}, + ), + MetricTimeseriesRequestDurationSeconds: prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "cortex_tenant", + Name: "timeseries_request_duration_seconds", + Help: "HTTP write request duration for tenant-specific timeseries in seconds, filtered by response code.", + Buckets: []float64{0.5, 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000, 1800000, 3600000}, + }, + []string{"code", "tenant"}, + ), + MetricTimeseriesRequestErrors: promauto.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "cortex_tenant", + Name: "timeseries_request_errors_total", + Help: "The total number of tenant-specific timeseries writes that yielded errors.", + }, + []string{"tenant"}, + ), + MetricTimeseriesRequests: promauto.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "cortex_tenant", + Name: "timeseries_requests_total", + Help: "The total number of tenant-specific timeseries writes.", + }, + []string{"tenant"}, + ), + } +} + +func (r *Recorder) Collectors() []prometheus.Collector { + return []prometheus.Collector{ + r.MetricTimeseriesBatchesReceived, + r.MetricTimeseriesBatchesReceivedBytes, + r.MetricTimeseriesReceived, + r.MetricTimeseriesRequestDurationSeconds, + r.MetricTimeseriesRequestErrors, + r.MetricTimeseriesRequests, + } +} + +// DeleteCondition deletes the condition metrics for the ref. +func (r *Recorder) DeleteMetricsForTenant(tenant *capsulev1beta2.Tenant) { + r.MetricTimeseriesRequests.DeleteLabelValues(tenant.Name) + r.MetricTimeseriesRequestDurationSeconds.DeleteLabelValues(tenant.Name) + r.MetricTimeseriesRequestErrors.DeleteLabelValues(tenant.Name) + r.MetricTimeseriesRequests.DeleteLabelValues(tenant.Name) +} diff --git a/internal/processor/processor.go b/internal/processor/processor.go new file mode 100644 index 0000000..12a199e --- /dev/null +++ b/internal/processor/processor.go @@ -0,0 +1,453 @@ +package processor + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/go-logr/logr" + "github.com/gogo/protobuf/proto" + "github.com/golang/snappy" + "github.com/google/uuid" + me "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + "github.com/prometheus/prometheus/prompb" + fh "github.com/valyala/fasthttp" + + "github.com/projectcapsule/cortex-tenant/internal/config" + "github.com/projectcapsule/cortex-tenant/internal/metrics" + "github.com/projectcapsule/cortex-tenant/internal/stores" +) + +type result struct { + code int + body []byte + duration float64 + tenant string + err error +} + +type Processor struct { + cfg config.Config + + srv *fh.Server + cli *fh.Client + + shuttingDown uint32 + + logr.Logger + + auth struct { + egressHeader []byte + } + + // Tenant store + store *stores.TenantStore + // Metrics Recorder + metrics *metrics.Recorder +} + +func NewProcessor( + log logr.Logger, + c config.Config, + store *stores.TenantStore, + metrics *metrics.Recorder, +) *Processor { + p := &Processor{ + cfg: c, + Logger: log, + store: store, + metrics: metrics, + } + + p.srv = &fh.Server{ + Name: "cortex-tenant", + Handler: p.handle, + + MaxRequestBodySize: 8 * 1024 * 1024, + + ReadTimeout: c.Timeout, + WriteTimeout: c.Timeout, + IdleTimeout: 60 * time.Second, + + Concurrency: c.Concurrency, + } + + p.cli = &fh.Client{ + Name: "cortex-tenant", + ReadTimeout: c.Timeout, + WriteTimeout: c.Timeout, + MaxConnWaitTimeout: 1 * time.Second, + MaxConnsPerHost: c.MaxConnsPerHost, + DialDualStack: c.EnableIPv6, + MaxConnDuration: c.MaxConnDuration, + } + + if c.Backend.Auth.Username != "" { + authString := []byte(fmt.Sprintf("%s:%s", c.Backend.Auth.Username, c.Backend.Auth.Password)) + p.auth.egressHeader = []byte("Basic " + base64.StdEncoding.EncodeToString(authString)) + } + + // For testing + if c.PipeOut != nil { + p.cli.Dial = func(_ string) (net.Conn, error) { + return c.PipeOut.Dial() + } + } + + return p +} + +// Start implements the Runnable interface +// It should block until the context is done (i.e. shutdown is triggered). +func (p *Processor) Start(ctx context.Context) error { + // Run your processor (blocking call) + if err := p.run(); err != nil { + return fmt.Errorf("failed to run processor: %w", err) + } + + // Wait for shutdown signal via the context + <-ctx.Done() + + // Perform any graceful shutdown/cleanup + if err := p.close(); err != nil { + return fmt.Errorf("failed to shutdown processor: %w", err) + } + + return nil +} + +//nolint:gosec +func (p *Processor) run() (err error) { + var l net.Listener + + // For testing + if p.cfg.PipeIn == nil { + if l, err = net.Listen("tcp", "0.0.0.0:8080"); err != nil { + return + } + } else { + l = p.cfg.PipeIn + } + + //nolint:errcheck + go p.srv.Serve(l) + + return +} + +func (p *Processor) handle(ctx *fh.RequestCtx) { + if bytes.Equal(ctx.Path(), []byte("/alive")) { + if atomic.LoadUint32(&p.shuttingDown) == 1 { + ctx.SetStatusCode(fh.StatusServiceUnavailable) + } + + return + } + + if !bytes.Equal(ctx.Request.Header.Method(), []byte("POST")) { + ctx.Error("Expecting POST", fh.StatusBadRequest) + + return + } + + if !bytes.Equal(ctx.Path(), []byte("/push")) { + ctx.SetStatusCode(fh.StatusNotFound) + + return + } + + p.metrics.MetricTimeseriesBatchesReceivedBytes.Observe(float64(ctx.Request.Header.ContentLength())) + p.metrics.MetricTimeseriesBatchesReceived.Inc() + + wrReqIn, err := p.unmarshal(ctx.Request.Body()) + if err != nil { + ctx.Error(err.Error(), fh.StatusBadRequest) + + return + } + + tenantPrefix := p.cfg.Tenant.Prefix + + if p.cfg.Tenant.PrefixPreferSource { + sourceTenantPrefix := string(ctx.Request.Header.Peek(p.cfg.Tenant.Header)) + if sourceTenantPrefix != "" { + tenantPrefix = sourceTenantPrefix + "-" + } + } + + clientIP := ctx.RemoteAddr() + reqID, _ := uuid.NewRandom() + + //nolint:nestif + if len(wrReqIn.Timeseries) == 0 { + // If there's metadata - just accept the request and drop it + if len(wrReqIn.Metadata) > 0 { + if p.cfg.Metadata && p.cfg.Tenant.Default != "" { + r := p.send(*p.cfg.Backend, clientIP, reqID, tenantPrefix+p.cfg.Tenant.Default, wrReqIn) + if r.err != nil { + ctx.Error(r.err.Error(), fh.StatusInternalServerError) + p.Error(r.err, "src=%s req_id=%s: unable to proxy metadata: %s", clientIP, reqID) + + return + } + + ctx.SetStatusCode(r.code) + ctx.SetBody(r.body) + } + + return + } + + ctx.Error("No timeseries found in the request", fh.StatusBadRequest) + + return + } + + m, err := p.createWriteRequests(wrReqIn) + if err != nil { + ctx.Error(err.Error(), fh.StatusBadRequest) + + return + } + + metricTenant := "" + + var errs *me.Error + + results := p.dispatch(clientIP, reqID, tenantPrefix, m) + + code, body := 0, []byte("Ok") + + // Return 204 regardless of errors if AcceptAll is enabled + if p.cfg.Tenant.AcceptAll { + code, body = 204, nil + + goto out + } + + for _, r := range results { + p.metrics.MetricTimeseriesRequests.WithLabelValues(r.tenant).Inc() + + if r.err != nil { + p.metrics.MetricTimeseriesRequestErrors.WithLabelValues(r.tenant).Inc() + errs = me.Append(errs, r.err) + p.Error(r.err, "request failed", "source", clientIP) + + continue + } + + if r.code < 200 || r.code >= 300 { + p.Info("src=%s req_id=%s HTTP code %d (%s)", clientIP, reqID, r.code, string(r.body)) + } + + if r.code > code { + code, body = r.code, r.body + } + + p.metrics.MetricTimeseriesRequestDurationSeconds.WithLabelValues(strconv.Itoa(r.code), metricTenant).Observe(r.duration) + } + + if errs.ErrorOrNil() != nil { + ctx.Error(errs.Error(), fh.StatusInternalServerError) + + return + } + +out: + // Pass back max status code from upstream response + ctx.SetBody(body) + ctx.SetStatusCode(code) +} + +func (p *Processor) createWriteRequests(wrReqIn *prompb.WriteRequest) (map[string]*prompb.WriteRequest, error) { + // Create per-tenant write requests + m := map[string]*prompb.WriteRequest{} + + for _, ts := range wrReqIn.Timeseries { + tenant, err := p.processTimeseries(&ts) + if err != nil { + return nil, err + } + + // Tenant & Total + p.metrics.MetricTimeseriesReceived.WithLabelValues(tenant).Inc() + p.metrics.MetricTimeseriesReceived.WithLabelValues("").Inc() + + wrReqOut, ok := m[tenant] + if !ok { + wrReqOut = &prompb.WriteRequest{} + m[tenant] = wrReqOut + } + + wrReqOut.Timeseries = append(wrReqOut.Timeseries, ts) + } + + return m, nil +} + +func (p *Processor) unmarshal(b []byte) (*prompb.WriteRequest, error) { + decoded, err := snappy.Decode(nil, b) + if err != nil { + return nil, errors.Wrap(err, "Unable to unpack Snappy") + } + + req := &prompb.WriteRequest{} + if err = proto.Unmarshal(decoded, req); err != nil { + return nil, errors.Wrap(err, "Unable to unmarshal protobuf") + } + + return req, nil +} + +func (p *Processor) marshal(wr *prompb.WriteRequest) (bufOut []byte, err error) { + b := make([]byte, wr.Size()) + + // Marshal to Protobuf + if _, err = wr.MarshalTo(b); err != nil { + return + } + + // Compress with Snappy + return snappy.Encode(nil, b), nil +} + +func (p *Processor) dispatch(clientIP net.Addr, reqID uuid.UUID, tenantPrefix string, m map[string]*prompb.WriteRequest) (res []result) { + var wg sync.WaitGroup + + res = make([]result, len(m)) + + i := 0 + + for tenant, wrReq := range m { + wg.Add(1) + + go func(idx int, tenant string, wrReq *prompb.WriteRequest) { + defer wg.Done() + + r := p.send(*p.cfg.Backend, clientIP, reqID, tenant, wrReq) + res[idx] = r + }(i, tenantPrefix+tenant, wrReq) + + i++ + } + + wg.Wait() + + return +} + +func removeOrdered(slice []prompb.Label, s int) []prompb.Label { + return append(slice[:s], slice[s+1:]...) +} + +func (p *Processor) processTimeseries(ts *prompb.TimeSeries) (tenant string, err error) { + idx := 0 + + var namespace string + + for i, l := range ts.Labels { + for _, configuredLabel := range p.cfg.Tenant.Labels { + if l.Name == configuredLabel { + p.Logger.Info("found", "label", configuredLabel, "value", l.Value) + + namespace = l.Value + idx = i + + break + } + } + } + + tenant = p.store.GetTenant(namespace) + + if tenant == "" { + if p.cfg.Tenant.Default == "" { + return "", fmt.Errorf("label(s): {'%s'} not found", strings.Join(p.cfg.Tenant.Labels, "','")) + } + + return p.cfg.Tenant.Default, nil + } + + if p.cfg.Tenant.LabelRemove { + // Order is important. See: + // https://github.com/thanos-io/thanos/issues/6452 + // https://github.com/prometheus/prometheus/issues/11505 + ts.Labels = removeOrdered(ts.Labels, idx) + } + + return +} + +func (p *Processor) send(backend config.CortexBackend, clientIP net.Addr, reqID uuid.UUID, tenant string, wr *prompb.WriteRequest) (r result) { + start := time.Now() + r.tenant = tenant + + req := fh.AcquireRequest() + resp := fh.AcquireResponse() + + defer func() { + fh.ReleaseRequest(req) + fh.ReleaseResponse(resp) + }() + + buf, err := p.marshal(wr) + if err != nil { + r.err = err + + return + } + + p.fillRequestHeaders(clientIP, reqID, tenant, req) + + if p.auth.egressHeader != nil { + req.Header.SetBytesV("Authorization", p.auth.egressHeader) + } + + req.Header.SetMethod(fh.MethodPost) + req.SetRequestURI(backend.URL) + req.SetBody(buf) + + if err = p.cli.DoTimeout(req, resp, p.cfg.Timeout); err != nil { + r.err = err + + return + } + + r.code = resp.Header.StatusCode() + r.body = make([]byte, len(resp.Body())) + copy(r.body, resp.Body()) + r.duration = time.Since(start).Seconds() + + return +} + +func (p *Processor) fillRequestHeaders( + clientIP net.Addr, + reqID uuid.UUID, + tenant string, + req *fh.Request, +) { + req.Header.Set("Content-Encoding", "snappy") + req.Header.Set("Content-Type", "application/x-protobuf") + req.Header.Set("X-Prometheus-Remote-Write-Version", "0.1.0") + req.Header.Set("X-Cortex-Tenant-Client", clientIP.String()) + req.Header.Set("X-Cortex-Tenant-ReqID", reqID.String()) + req.Header.Set(p.cfg.Tenant.Header, tenant) +} + +func (p *Processor) close() (err error) { + // Signal that we're shutting down + atomic.StoreUint32(&p.shuttingDown, 1) + // Let healthcheck detect that we're offline + time.Sleep(p.cfg.TimeoutShutdown) + // Shutdown + return p.srv.Shutdown() +} diff --git a/internal/processor/processor_suite_test.go b/internal/processor/processor_suite_test.go new file mode 100644 index 0000000..3b80d75 --- /dev/null +++ b/internal/processor/processor_suite_test.go @@ -0,0 +1,13 @@ +package processor_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestProcessor(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Processor Suite") +} diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go new file mode 100644 index 0000000..76f33c3 --- /dev/null +++ b/internal/processor/processor_test.go @@ -0,0 +1,319 @@ +package processor + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "time" + + "github.com/go-logr/logr" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/prometheus/prometheus/prompb" + fh "github.com/valyala/fasthttp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/projectcapsule/cortex-tenant/internal/config" + "github.com/projectcapsule/cortex-tenant/internal/metrics" + "github.com/projectcapsule/cortex-tenant/internal/stores" +) + +var _ = Describe("Processor Forwarding", func() { + var ( + proc *Processor + fakeTarget *httptest.Server + receivedMu sync.Mutex + receivedHeader http.Header + ctx context.Context + cancel context.CancelFunc + cfg config.Config + store *stores.TenantStore + metric *metrics.Recorder + ) + + metric = metrics.MustMakeRecorder() // or a mock recorder + + BeforeEach(func() { + // Create a fake target server that records request headers. + fakeTarget = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMu.Lock() + receivedHeader = r.Header.Clone() + receivedMu.Unlock() + w.Header().Set("Connection", "close") + w.WriteHeader(http.StatusOK) + })) + + // Initialize configuration for the processor. + // Ensure cfg.Target points to fakeTarget.URL. + cfg = config.Config{ + Backend: &config.CortexBackend{ + URL: fakeTarget.URL, + }, + Timeout: 5 * time.Second, + // Set other fields as needed, for example Tenant config. + Tenant: &config.TenantConfig{ + Labels: []string{ + "namespace", + "target_namespace", + }, + Header: "X-Scope-OrgID", + Default: "default", + Prefix: "test-", + PrefixPreferSource: false, + }, + } + + // Initialize any required dependencies (store, metrics, logger). + store = stores.NewTenantStore() // or a suitable mock + store.Update(&capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "solar", + Namespace: "solar", + }, + Status: capsulev1beta2.TenantStatus{ + Namespaces: []string{"solar-one", "solar-two", "solar-three"}, + }, + }) + store.Update(&capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "oil", + Namespace: "oil", + }, + Status: capsulev1beta2.TenantStatus{ + Namespaces: []string{"oil-one", "oil-two", "oil-three"}, + }, + }) + + // Create the processor. + // Start the processor webserver in a separate goroutine. + ctx, cancel = context.WithCancel(context.Background()) + + log, _ := logr.FromContext(ctx) + + // Create the processor. + proc = NewProcessor(log, cfg, store, metric) + + go func() { + if err := proc.Start(ctx); err != nil { + log.Error(err, "processor failed") + } + }() + + // Allow some time for the processor to start. + time.Sleep(500 * time.Millisecond) + }) + + AfterEach(func() { + cancel() + fakeTarget.Close() + }) + + It("should correctly set headers", func() { + By("settings default tenant", func() { + + // Prepare a minimal prompb.WriteRequest. + wr := &prompb.WriteRequest{ + Timeseries: []prompb.TimeSeries{ + { + Labels: []prompb.Label{ + {Name: "job", Value: "test"}, + {Name: "instance", Value: "localhost:9090"}, + }, + Samples: []prompb.Sample{ + {Value: 123, Timestamp: time.Now().UnixMilli()}, + }, + }, + }, + } + + // Marshal and compress using the processor helper. + buf, err := proc.marshal(wr) + Expect(err).NotTo(HaveOccurred()) + + // Build a POST request to the processor's /push endpoint. + // Since processor uses fasthttp, use its client for the test. + var req fh.Request + var resp fh.Response + req.SetRequestURI("http://127.0.0.1:8080/push") + req.Header.SetMethod(fh.MethodPost) + req.Header.Set("Content-Encoding", "snappy") + req.Header.Set("Content-Type", "application/x-protobuf") + req.SetBody(buf) + + // Send the request using fasthttp. + err = fh.DoTimeout(&req, &resp, cfg.Timeout) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(fh.StatusOK)) + + // Wait until the fake target receives the forwarded request. + Eventually(func() http.Header { + receivedMu.Lock() + defer receivedMu.Unlock() + return receivedHeader + }, 5*time.Second, 200*time.Millisecond).ShouldNot(BeEmpty()) + + // Verify that the forwarded request contains the expected header. + receivedMu.Lock() + defer receivedMu.Unlock() + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("X-Scope-OrgID"), []string{"test-default"})) + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("X-Prometheus-Remote-Write-Version"), []string{"0.1.0"})) + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("Content-Encoding"), []string{"snappy"})) + }) + + By("proxy correct tenant (solar)", func() { + + // Prepare a minimal prompb.WriteRequest. + wr := &prompb.WriteRequest{ + Timeseries: []prompb.TimeSeries{ + { + Labels: []prompb.Label{ + {Name: "job", Value: "test"}, + {Name: "instance", Value: "localhost:9090"}, + {Name: "namespace", Value: "solar-three"}, + }, + Samples: []prompb.Sample{ + {Value: 123, Timestamp: time.Now().UnixMilli()}, + }, + }, + }, + } + + // Marshal and compress using the processor helper. + buf, err := proc.marshal(wr) + Expect(err).NotTo(HaveOccurred()) + + // Build a POST request to the processor's /push endpoint. + // Since processor uses fasthttp, use its client for the test. + var req fh.Request + var resp fh.Response + req.SetRequestURI("http://127.0.0.1:8080/push") + req.Header.SetMethod(fh.MethodPost) + req.Header.Set("Content-Encoding", "snappy") + req.Header.Set("Content-Type", "application/x-protobuf") + req.SetBody(buf) + + // Send the request using fasthttp. + err = fh.DoTimeout(&req, &resp, cfg.Timeout) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(fh.StatusOK)) + + // Wait until the fake target receives the forwarded request. + Eventually(func() http.Header { + receivedMu.Lock() + defer receivedMu.Unlock() + return receivedHeader + }, 5*time.Second, 200*time.Millisecond).ShouldNot(BeEmpty()) + + // Verify that the forwarded request contains the expected header. + receivedMu.Lock() + defer receivedMu.Unlock() + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("X-Scope-OrgID"), []string{cfg.Tenant.Prefix + "solar"})) + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("X-Prometheus-Remote-Write-Version"), []string{"0.1.0"})) + }) + + By("proxy correct tenant (oil)", func() { + + // Prepare a minimal prompb.WriteRequest. + wr := &prompb.WriteRequest{ + Timeseries: []prompb.TimeSeries{ + { + Labels: []prompb.Label{ + {Name: "job", Value: "test"}, + {Name: "instance", Value: "localhost:9090"}, + {Name: "target_namespace", Value: "oil-one"}, + }, + Samples: []prompb.Sample{ + {Value: 123, Timestamp: time.Now().UnixMilli()}, + }, + }, + }, + } + + // Marshal and compress using the processor helper. + buf, err := proc.marshal(wr) + Expect(err).NotTo(HaveOccurred()) + + // Build a POST request to the processor's /push endpoint. + // Since processor uses fasthttp, use its client for the test. + var req fh.Request + var resp fh.Response + req.SetRequestURI("http://127.0.0.1:8080/push") + req.Header.SetMethod(fh.MethodPost) + req.Header.Set("Content-Encoding", "snappy") + req.Header.Set("Content-Type", "application/x-protobuf") + req.SetBody(buf) + + // Send the request using fasthttp. + err = fh.DoTimeout(&req, &resp, cfg.Timeout) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(fh.StatusOK)) + + // Wait until the fake target receives the forwarded request. + Eventually(func() http.Header { + receivedMu.Lock() + defer receivedMu.Unlock() + return receivedHeader + }, 5*time.Second, 200*time.Millisecond).ShouldNot(BeEmpty()) + + // Verify that the forwarded request contains the expected header. + receivedMu.Lock() + defer receivedMu.Unlock() + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("X-Scope-OrgID"), []string{cfg.Tenant.Prefix + "oil"})) + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("X-Prometheus-Remote-Write-Version"), []string{"0.1.0"})) + }) + + By("default on no match", func() { + + // Prepare a minimal prompb.WriteRequest. + wr := &prompb.WriteRequest{ + Timeseries: []prompb.TimeSeries{ + { + Labels: []prompb.Label{ + {Name: "job", Value: "test"}, + {Name: "instance", Value: "localhost:9090"}, + {Name: "target_namespace", Value: "oil-prod"}, + }, + Samples: []prompb.Sample{ + {Value: 123, Timestamp: time.Now().UnixMilli()}, + }, + }, + }, + } + + // Marshal and compress using the processor helper. + buf, err := proc.marshal(wr) + Expect(err).NotTo(HaveOccurred()) + + // Build a POST request to the processor's /push endpoint. + // Since processor uses fasthttp, use its client for the test. + var req fh.Request + var resp fh.Response + req.SetRequestURI("http://127.0.0.1:8080/push") + req.Header.SetMethod(fh.MethodPost) + req.Header.Set("Content-Encoding", "snappy") + req.Header.Set("Content-Type", "application/x-protobuf") + req.SetBody(buf) + + // Send the request using fasthttp. + err = fh.DoTimeout(&req, &resp, cfg.Timeout) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.StatusCode()).To(Equal(fh.StatusOK)) + + // Wait until the fake target receives the forwarded request. + Eventually(func() http.Header { + receivedMu.Lock() + defer receivedMu.Unlock() + return receivedHeader + }, 5*time.Second, 200*time.Millisecond).ShouldNot(BeEmpty()) + + // Verify that the forwarded request contains the expected header. + receivedMu.Lock() + defer receivedMu.Unlock() + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("X-Scope-OrgID"), []string{cfg.Tenant.Prefix + cfg.Tenant.Default})) + Expect(receivedHeader).To(HaveKeyWithValue(http.CanonicalHeaderKey("X-Prometheus-Remote-Write-Version"), []string{"0.1.0"})) + }) + + }) +}) diff --git a/internal/stores/store.go b/internal/stores/store.go new file mode 100644 index 0000000..43a6bac --- /dev/null +++ b/internal/stores/store.go @@ -0,0 +1,59 @@ +package stores + +import ( + "sync" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" +) + +type TenantStore struct { + sync.RWMutex + tenants map[string]string +} + +func NewTenantStore() *TenantStore { + return &TenantStore{ + tenants: make(map[string]string), + } +} + +func (s *TenantStore) GetTenant(namespace string) string { + s.RLock() + defer s.RUnlock() + + return s.tenants[namespace] +} + +func (s *TenantStore) Update(tenant *capsulev1beta2.Tenant) { + s.Lock() + defer s.Unlock() + + currentNamespaces := make(map[string]struct{}, len(tenant.Status.Namespaces)) + for _, ns := range tenant.Status.Namespaces { + currentNamespaces[ns] = struct{}{} + } + + for ns, t := range s.tenants { + if t == tenant.Name { + // If ns is not in the updated namespace list, delete it + if _, exists := currentNamespaces[ns]; !exists { + delete(s.tenants, ns) + } + } + } + + for _, ns := range tenant.Status.Namespaces { + s.tenants[ns] = tenant.Name + } +} + +func (s *TenantStore) Delete(tenant *capsulev1beta2.Tenant) { + s.Lock() + defer s.Unlock() + + for ns, t := range s.tenants { + if t == tenant.Name { + delete(s.tenants, ns) + } + } +} diff --git a/internal/stores/store_test.go b/internal/stores/store_test.go new file mode 100644 index 0000000..308ea27 --- /dev/null +++ b/internal/stores/store_test.go @@ -0,0 +1,80 @@ +package stores_test + +import ( + "sync" + "testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/cortex-tenant/internal/stores" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + . "github.com/onsi/gomega" +) + +// TestTenantStore_Basic verifies that updating, retrieving, and deleting tenants works as expected. +func TestTenantStore_Basic(t *testing.T) { + RegisterTestingT(t) + + store := stores.NewTenantStore() + + tenant := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant1", + }, + Status: capsulev1beta2.TenantStatus{ + Namespaces: []string{"ns1", "ns2"}, + }, + } + + // Update the store with tenant1 for ns1 and ns2. + store.Update(tenant) + Expect(store.GetTenant("ns1")).To(Equal("tenant1")) + Expect(store.GetTenant("ns2")).To(Equal("tenant1")) + + // Now update tenant: remove ns1 and add ns3. + tenant.Status.Namespaces = []string{"ns2", "ns3"} + store.Update(tenant) + Expect(store.GetTenant("ns1")).To(Equal("")) + Expect(store.GetTenant("ns2")).To(Equal("tenant1")) + Expect(store.GetTenant("ns3")).To(Equal("tenant1")) + + // Delete tenant; ns2 and ns3 should be removed. + store.Delete(tenant) + Expect(store.GetTenant("ns2")).To(Equal("")) + Expect(store.GetTenant("ns3")).To(Equal("")) +} + +// TestTenantStore_Concurrent verifies that concurrent reads work safely. +func TestTenantStore_Concurrent(t *testing.T) { + RegisterTestingT(t) + + store := stores.NewTenantStore() + tenant := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tenant1", + }, + Status: capsulev1beta2.TenantStatus{ + Namespaces: []string{"ns1", "ns2", "ns3"}, + }, + } + store.Update(tenant) + + var wg sync.WaitGroup + numGoroutines := 50 + iterations := 1000 + + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + // Concurrently read from the store. + _ = store.GetTenant("ns1") + _ = store.GetTenant("ns2") + _ = store.GetTenant("ns3") + } + }() + } + + wg.Wait() +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..9c90bc9 --- /dev/null +++ b/renovate.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":dependencyDashboard"], + "baseBranches": ["main"], + "prHourlyLimit": 0, + "prConcurrentLimit": 0, + "branchConcurrentLimit": 0, + "mode": "full", + "commitMessageLowerCase": "auto", + "semanticCommits": "enabled", + "flux": { + "fileMatch": ["^.*flux\\.yaml$"] + }, + "packageRules": [ + { + "matchManagers": ["github-actions", "flux"], + "groupName": "all-ci-dependencies", + "updateTypes": ["major", "minor", "patch"] + } + ], + "customManagers": [ + { + "customType": "regex", + "fileMatch": ["^Makefile$"], + "matchStrings": [ + "(?[A-Z0-9_]+)_VERSION\\s*[:=?]+\\s*\"?(?[^\"\\r\\n]+)\"?[\\s\\S]*?(?[A-Z0-9_]+)_LOOKUP\\s*[:=?]+\\s*\"?(?[^\"\\r\\n]+)\"?(?:[\\s\\S]*?(?[A-Z0-9_]+)_SOURCE\\s*[:=?]+\\s*\"?(?[^\"\\r\\n]+)\"?)?" + ], + "depNameTemplate": "{{lookupValue}}", + "datasourceTemplate": "{{#sourceValue}}{{sourceValue}}{{/sourceValue}}{{^sourceValue}}github-tags{{/sourceValue}}", + "lookupNameTemplate": "{{lookupValue}}", + "versioningTemplate": "semver" + }, + { + "customType": "regex", + "fileMatch": [".*\\.pre-commit-config\\.ya?ml$"], + "matchStrings": [ + "repo:\\s*https://github\\.com/(?[^/]+/[^\\s]+)[\\s\\S]*?rev:\\s*(?v?\\d+\\.\\d+\\.\\d+)" + ], + "depNameTemplate": "{{lookupValue}}", + "datasourceTemplate": "github-tags", + "lookupNameTemplate": "{{lookupValue}}", + "versioningTemplate": "semver" + } + ] +} From 20a3bb511654838f9a0d6936de5045e9b805dfff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= Date: Sat, 1 Mar 2025 01:27:51 +0100 Subject: [PATCH 2/4] feat(controller): hard fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Oliver Bähler --- .github/workflows/coverage.yml | 2 +- .github/workflows/docker-publish.yml | 8 ++-- .github/workflows/e2e.yaml | 40 ------------------- .github/workflows/helm-publish.yml | 8 ++-- .goreleaser.yml | 8 +--- Makefile | 14 +++---- README.md | 10 ++--- .../.helmignore | 0 .../.schema.yaml | 0 .../Chart.yaml | 6 +-- .../{cortex-tenant => cortex-proxy}/README.md | 6 +-- .../README.md.gotmpl | 6 +-- .../artifacthub-repo.yml | 0 .../ci/test-values.yaml | 0 .../templates/_helpers.tpl | 0 .../templates/configuration.yaml | 0 .../templates/deployment.yaml | 0 .../templates/hpa.yaml | 0 .../templates/pdb.yaml | 0 .../templates/rbac.yaml | 0 .../templates/rules.yaml | 0 .../templates/service.yaml | 0 .../templates/serviceaccount.yaml | 0 .../templates/servicemonitor.yaml | 0 .../values.schema.json | 0 .../values.yaml | 0 cmd/main.go | 11 +++-- go.mod | 2 +- internal/config/config_test.go | 2 +- internal/controllers/reconciler.go | 5 +-- internal/processor/processor.go | 7 ++-- internal/processor/processor_test.go | 6 +-- internal/stores/store_test.go | 2 +- 33 files changed, 48 insertions(+), 95 deletions(-) delete mode 100644 .github/workflows/e2e.yaml rename charts/{cortex-tenant => cortex-proxy}/.helmignore (100%) rename charts/{cortex-tenant => cortex-proxy}/.schema.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/Chart.yaml (77%) rename charts/{cortex-tenant => cortex-proxy}/README.md (98%) rename charts/{cortex-tenant => cortex-proxy}/README.md.gotmpl (93%) rename charts/{cortex-tenant => cortex-proxy}/artifacthub-repo.yml (100%) rename charts/{cortex-tenant => cortex-proxy}/ci/test-values.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/_helpers.tpl (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/configuration.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/deployment.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/hpa.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/pdb.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/rbac.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/rules.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/service.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/serviceaccount.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/templates/servicemonitor.yaml (100%) rename charts/{cortex-tenant => cortex-proxy}/values.schema.json (100%) rename charts/{cortex-tenant => cortex-proxy}/values.yaml (100%) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 43c41c0..86af1e7 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -83,7 +83,7 @@ jobs: uses: codecov/codecov-action@13ce06bfc6bbe3ecf90edbbf1bc32fe5978ca1d3 # v5.3.1 with: token: ${{ secrets.CODECOV_TOKEN }} - slug: projectcapsule/cortex-tenant + slug: projectcapsule/cortex-proxy files: ./coverage.out fail_ci_if_error: true verbose: true diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9ef8cb5..bdcf29b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -38,9 +38,9 @@ jobs: repository: ${{ github.repository_owner }} version: ${{ steps.extract_version.outputs.version }} sign-image: true - sbom-name: cortex-tenant - sbom-repository: ghcr.io/${{ github.repository_owner }}/cortex-tenant - signature-repository: ghcr.io/${{ github.repository_owner }}/cortex-tenant + sbom-name: cortex-proxy + sbom-repository: ghcr.io/${{ github.repository_owner }}/cortex-proxy + signature-repository: ghcr.io/${{ github.repository_owner }}/cortex-proxy main-path: ./cmd/ env: REPOSITORY: ${{ github.repository }} @@ -52,7 +52,7 @@ jobs: actions: read # To read the workflow path. uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0 with: - image: ghcr.io/${{ github.repository_owner }}/cortex-tenant + image: ghcr.io/${{ github.repository_owner }}/cortex-proxy digest: "${{ needs.publish-images.outputs.container-digest }}" registry-username: ${{ github.actor }} secrets: diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml deleted file mode 100644 index d39ea4b..0000000 --- a/.github/workflows/e2e.yaml +++ /dev/null @@ -1,40 +0,0 @@ -name: e2e -permissions: {} - -on: - pull_request: - branches: - - "*" - paths: - - '.github/workflows/e2e.yml' - - 'api/**' - - 'cmd/**' - - 'internal/**' - - 'e2e/*' - - '.ko.yaml' - - 'go.*' - - 'main.go' - - 'Makefile' - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - kind: - name: Kubernetes - strategy: - fail-fast: false - matrix: - k8s-version: - - "" - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - fetch-depth: 0 - - uses: actions/setup-go@f111f3307d8850f501ac008e886eec1fd1932a34 # v5.3.0 - with: - go-version-file: 'go.mod' - - name: e2e testing - run: KIND_K8S_VERSION="${{ matrix.k8s-version }}" make e2e diff --git a/.github/workflows/helm-publish.yml b/.github/workflows/helm-publish.yml index cc9e403..fa0512a 100644 --- a/.github/workflows/helm-publish.yml +++ b/.github/workflows/helm-publish.yml @@ -28,15 +28,15 @@ jobs: with: registry: ghcr.io repository: ${{ github.repository_owner }}/charts - name: "cortex-tenant" - path: "./charts/cortex-tenant/" + name: "cortex-proxy" + path: "./charts/cortex-proxy/" app-version: ${{ steps.extract_version.outputs.version }} version: ${{ steps.extract_version.outputs.version }} registry-username: ${{ github.actor }} registry-password: ${{ secrets.GITHUB_TOKEN }} update-dependencies: 'false' # Defaults to false sign-image: 'true' - signature-repository: ghcr.io/${{ github.repository_owner }}/charts/cortex-tenant + signature-repository: ghcr.io/${{ github.repository_owner }}/charts/cortex-proxy helm-provenance: needs: publish-helm permissions: @@ -45,7 +45,7 @@ jobs: actions: read # To read the workflow path. uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0 with: - image: ghcr.io/${{ github.repository_owner }}/charts/cortex-tenant + image: ghcr.io/${{ github.repository_owner }}/charts/cortex-proxy digest: "${{ needs.publish-helm.outputs.chart-digest }}" registry-username: ${{ github.actor }} secrets: diff --git a/.goreleaser.yml b/.goreleaser.yml index a37a2ed..ceec1a7 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,4 +1,4 @@ -project_name: capsule +project_name: cortex-proxy env: - COSIGN_EXPERIMENTAL=true - GO111MODULE=on @@ -38,11 +38,7 @@ release: - `ghcr.io/projectcapsule/{{ .ProjectName }}:latest` **Helm Chart** - View this release on [Artifact Hub](https://artifacthub.io/packages/helm/projectcapsule/capsule/{{ .Version }}) or use the OCI helm chart: - - - `ghcr.io/projectcapsule/charts/{{ .ProjectName }}:{{ .Version }}` - - [Review the Major Changes section first before upgrading to a new version](https://artifacthub.io/packages/helm/projectcapsule/capsule/{{ .Version }}#major-changes) + [![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/cortex-proxy)](https://artifacthub.io/packages/search?repo=cortex-proxy) **Kubernetes compatibility** diff --git a/Makefile b/Makefile index 10850b2..7ec6bdd 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ GOARCH ?= $(shell go env GOARCH) # Defaults REGISTRY ?= ghcr.io -REPOSITORY ?= projectcapsule/cortex-tenant +REPOSITORY ?= projectcapsule/cortex-proxy GIT_TAG_COMMIT ?= $(shell git rev-parse --short $(VERSION)) GIT_MODIFIED_1 ?= $(shell git diff $(GIT_HEAD_COMMIT) $(GIT_TAG_COMMIT) --quiet && echo "" || echo ".dev") GIT_MODIFIED_2 ?= $(shell git diff --quiet && echo "" || echo ".dirty") @@ -120,8 +120,8 @@ ko-publish-all: ko-publish-controller SRC_ROOT = $(shell git rev-parse --show-toplevel) helm-controller-version: - $(eval VERSION := $(shell grep 'appVersion:' charts/cortex-tenant/Chart.yaml | awk '{print $$2}')) - $(eval KO_TAGS := $(shell grep 'appVersion:' charts/cortex-tenant/Chart.yaml | awk '{print $$2}')) + $(eval VERSION := $(shell grep 'appVersion:' charts/cortex-proxy/Chart.yaml | awk '{print $$2}')) + $(eval KO_TAGS := $(shell grep 'appVersion:' charts/cortex-proxy/Chart.yaml | awk '{print $$2}')) helm-docs: helm-doc @@ -131,7 +131,7 @@ helm-lint: ct @$(CT) lint --config .github/configs/ct.yaml --validate-yaml=false --all --debug helm-schema: helm-plugin-schema - cd charts/cortex-tenant && $(HELM) schema -output values.schema.json + cd charts/cortex-proxy && $(HELM) schema -output values.schema.json helm-test: kind ct @$(KIND) create cluster --wait=60s --name $(KIND_K8S_NAME) --image=kindest/node:$(KIND_K8S_VERSION) @@ -172,13 +172,13 @@ e2e-install-addon: e2e-load-image --dependency-update \ --debug \ --install \ - --namespace cortex-tenant \ + --namespace monitoring-system \ --create-namespace \ --set 'image.pullPolicy=Never' \ --set "image.tag=$(VERSION)" \ --set args.logLevel=10 \ - cortex-tenant \ - ./charts/cortex-tenant + cortex-proxy \ + ./charts/cortex-proxy e2e-install-distro: @$(KUBECTL) kustomize e2e/objects/flux/ | kubectl apply -f - diff --git a/README.md b/README.md index 137f383..37778a4 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ [!IMPORTANT] -This project is a permanent hard-fork of the origin project. +This project is a permanent hard-fork of the [origin project](https://github.com/blind-oracle/cortex-tenant). # Capsule ❤️ Cortex ![Capsule Cortex](docs/images/logo.png)