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..86af1e7 --- /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-proxy + 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..bdcf29b --- /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-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 }} + 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-proxy + digest: "${{ needs.publish-images.outputs.container-digest }}" + registry-username: ${{ github.actor }} + secrets: + registry-password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/helm-publish.yml b/.github/workflows/helm-publish.yml new file mode 100644 index 0000000..fa0512a --- /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-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-proxy + 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-proxy + 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..02b2f68 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,32 @@ +name: Linting +permissions: {} +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + 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..ceec1a7 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,102 @@ +project_name: cortex-proxy +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** + [![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** + + [!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..7ec6bdd --- /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-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") +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-proxy/Chart.yaml | awk '{print $$2}')) + $(eval KO_TAGS := $(shell grep 'appVersion:' charts/cortex-proxy/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-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) + @$(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 monitoring-system \ + --create-namespace \ + --set 'image.pullPolicy=Never' \ + --set "image.tag=$(VERSION)" \ + --set args.logLevel=10 \ + cortex-proxy \ + ./charts/cortex-proxy + +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..37778a4 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +[!IMPORTANT] +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) + +

+ + 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-proxy/.helmignore b/charts/cortex-proxy/.helmignore new file mode 100644 index 0000000..4dc2633 --- /dev/null +++ b/charts/cortex-proxy/.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-proxy/.schema.yaml b/charts/cortex-proxy/.schema.yaml new file mode 100644 index 0000000..3febf19 --- /dev/null +++ b/charts/cortex-proxy/.schema.yaml @@ -0,0 +1,3 @@ +input: + - values.yaml + - ci/test-values.yaml diff --git a/charts/cortex-proxy/Chart.yaml b/charts/cortex-proxy/Chart.yaml new file mode 100644 index 0000000..d3d519b --- /dev/null +++ b/charts/cortex-proxy/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +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-proxy +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-proxy diff --git a/charts/cortex-proxy/README.md b/charts/cortex-proxy/README.md new file mode 100644 index 0000000..fc78d89 --- /dev/null +++ b/charts/cortex-proxy/README.md @@ -0,0 +1,125 @@ +# Capsule ❤️ Cortex + +![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-proxy -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-proxy --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 | 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) | +| 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.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 | +| 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-proxy/README.md.gotmpl b/charts/cortex-proxy/README.md.gotmpl new file mode 100644 index 0000000..bad698e --- /dev/null +++ b/charts/cortex-proxy/README.md.gotmpl @@ -0,0 +1,77 @@ +# Capsule ❤️ Cortex + +![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-proxy -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-proxy --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-proxy/artifacthub-repo.yml b/charts/cortex-proxy/artifacthub-repo.yml new file mode 100644 index 0000000..dfd0775 --- /dev/null +++ b/charts/cortex-proxy/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-proxy/ci/test-values.yaml b/charts/cortex-proxy/ci/test-values.yaml new file mode 100644 index 0000000..edd9aac --- /dev/null +++ b/charts/cortex-proxy/ci/test-values.yaml @@ -0,0 +1,2 @@ +image: + pullPolicy: Never diff --git a/charts/cortex-proxy/templates/_helpers.tpl b/charts/cortex-proxy/templates/_helpers.tpl new file mode 100644 index 0000000..197eda3 --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/configuration.yaml b/charts/cortex-proxy/templates/configuration.yaml new file mode 100644 index 0000000..8e35fda --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/deployment.yaml b/charts/cortex-proxy/templates/deployment.yaml new file mode 100644 index 0000000..a202958 --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/hpa.yaml b/charts/cortex-proxy/templates/hpa.yaml new file mode 100644 index 0000000..754e469 --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/pdb.yaml b/charts/cortex-proxy/templates/pdb.yaml new file mode 100644 index 0000000..1b3b7a7 --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/rbac.yaml b/charts/cortex-proxy/templates/rbac.yaml new file mode 100644 index 0000000..dd31d9a --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/rules.yaml b/charts/cortex-proxy/templates/rules.yaml new file mode 100644 index 0000000..324c8db --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/service.yaml b/charts/cortex-proxy/templates/service.yaml new file mode 100644 index 0000000..75e6c69 --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/serviceaccount.yaml b/charts/cortex-proxy/templates/serviceaccount.yaml new file mode 100644 index 0000000..6b1382c --- /dev/null +++ b/charts/cortex-proxy/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-proxy/templates/servicemonitor.yaml b/charts/cortex-proxy/templates/servicemonitor.yaml new file mode 100644 index 0000000..b76aac0 --- /dev/null +++ b/charts/cortex-proxy/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-proxy/values.schema.json b/charts/cortex-proxy/values.schema.json new file mode 100644 index 0000000..c564564 --- /dev/null +++ b/charts/cortex-proxy/values.schema.json @@ -0,0 +1,445 @@ +{ + "$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" + }, + "selector": { + "properties": {}, + "type": "object" + }, + "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-proxy/values.yaml b/charts/cortex-proxy/values.yaml new file mode 100644 index 0000000..16add5d --- /dev/null +++ b/charts/cortex-proxy/values.yaml @@ -0,0 +1,263 @@ +# 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 + # -- 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 + # Authentication (optional) + auth: + # -- Username + 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: [] + # -- 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..dd6a9cc --- /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" + "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" + 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" +) + +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(), + Selector: cfg.Selector.Selector(), + // 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..a1dffc4 --- /dev/null +++ b/config.yml @@ -0,0 +1,24 @@ +backend: + url: http://127.0.0.1:9091/receive + auth: + username: foo + password: bar +# selector: +# matchLabels: +# test: me +ipv6: false +maxConnectionsPerHost: 64 +timeout: 10s +timeoutShutdown: 0s +concurrency: 10 +metadata: false +tenant: + labels: + - tenant + - other_tenant + prefix: "" + prefixPreferSource: false + labelRemove: true + header: X-Scope-OrgID + default: "" + acceptAll: 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 0000000..38255ba Binary files /dev/null and b/docs/images/capsule-cortex.gif differ diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 0000000..70f9739 Binary files /dev/null and b/docs/images/logo.png differ 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..6ca471c --- /dev/null +++ b/go.mod @@ -0,0 +1,97 @@ +module github.com/projectcapsule/cortex-proxy + +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..5ce136e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,87 @@ +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"` + + Selector LabelSelector `yaml:"selector,omitempty"` + + 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..de841b3 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,83 @@ +package config_test + +import ( + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/projectcapsule/cortex-proxy/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 +selector: + matchLabels: + special: "tenants" +` + 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/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 new file mode 100644 index 0000000..e8aac3d --- /dev/null +++ b/internal/controllers/reconciler.go @@ -0,0 +1,112 @@ +package controllers + +import ( + "context" + "fmt" + + "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/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" +) + +type TenantController struct { + client.Client + Metrics *metrics.Recorder + Scheme *runtime.Scheme + Store *stores.TenantStore + Log logr.Logger + Selector *metav1.LabelSelector +} + +func (r *TenantController) SetupWithManager(mgr ctrl.Manager) error { + 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) { + 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, c client.Client) (err error) { + tnts := &capsulev1beta2.TenantList{} + + 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) + } + + 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..81c9a93 --- /dev/null +++ b/internal/processor/processor.go @@ -0,0 +1,452 @@ +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/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" +) + +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..2424a1d --- /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-proxy/internal/config" + "github.com/projectcapsule/cortex-proxy/internal/metrics" + "github.com/projectcapsule/cortex-proxy/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..22b483d --- /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-proxy/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" + } + ] +}