diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..6ff2842b87 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# https://docs.docker.com/build/concepts/context/#dockerignore-files +# https://docs.podman.io/en/latest/markdown/podman-build.1.html#files +/.git +/bin +/hack +!/hack/tools/queries diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000..91a1f534ef --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +# https://editorconfig.org +# +# https://neovim.io/doc/user/editorconfig.html +# https://github.com/editorconfig/editorconfig-emacs +# https://plugins.jetbrains.com/plugin/7294-editorconfig +# https://marketplace.visualstudio.com/items/EditorConfig.EditorConfig + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{go,sh}] +indent_size = tab +indent_style = tab + +[*.{md,yml,yaml}] +indent_size = 2 +indent_style = space + +[Makefile] +indent_style = tab diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..c698441f73 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# https://docs.gitlab.com/user/project/merge_requests/changes#collapse-generated-files +# https://github.com/github-linguist/linguist/blob/-/docs/overrides.md#generated-code +# https://git-scm.com/docs/gitattributes#_defining_macro_attributes +[attr]generated gitlab-generated linguist-generated + +/internal/collector/generated/*.json generated diff --git a/.github/actions/awk-matcher.json b/.github/actions/awk-matcher.json deleted file mode 100644 index 852a723577..0000000000 --- a/.github/actions/awk-matcher.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "problemMatcher": [ - { - "owner": "awk", - "pattern": [ - { - "regexp": "^([^:]+):([^ ]+) (([^:]+):.*)$", - "file": 1, "line": 2, "message": 3, "severity": 4 - } - ] - } - ] -} diff --git a/.github/actions/trivy/action.yaml b/.github/actions/trivy/action.yaml new file mode 100644 index 0000000000..bc29928487 --- /dev/null +++ b/.github/actions/trivy/action.yaml @@ -0,0 +1,138 @@ +# Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +# schema documentation: https://docs.github.com/actions/sharing-automations/creating-actions/metadata-syntax-for-github-actions +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json + +name: Trivy +description: Scan this project using Trivy + +# The Trivy team maintains an action, but it has trouble caching its vulnerability data: +# https://github.com/aquasecurity/trivy-action/issues/389 +# +# 1. It caches vulnerability data once per calendar day, despite Trivy wanting +# to download more frequently than that. +# 2. When it fails to download the data, it fails the workflow *and* caches +# the incomplete data. +# 3. When (1) and (2) coincide, every following run that day *must* update the data, +# producing more opportunities for (2) and more failed workflows. +# +# The action below uses any recent cache matching `cache-prefix` and calculates a cache key +# derived from the data Trivy downloads. An older database is better than no scans at all. +# When a run successfully updates the data, that data is cached and available to other runs. + +inputs: + cache: + default: restore,success,use + description: >- + What Trivy data to cache; one or more of restore, save, success, or use. + The value "use" instructs Trivy to read and write to its cache. + The value "restore" loads the Trivy cache from GitHub. + The value "success" saves the Trivy cache to GitHub when Trivy succeeds. + The value "save" saves the Trivy cache to GitHub regardless of Trivy. + + database: + default: update + description: >- + How Trivy should handle its data; one of update or skip. + The value "skip" fetches no Trivy data at all. + + setup: + default: v0.65.0,cache + description: >- + How to install Trivy; one or more of version, none, or cache. + The value "none" does not install Trivy at all. + + cache-directory: + default: ${{ github.workspace }}/.cache/trivy + description: >- + Directory where Trivy should store its data + + cache-prefix: + default: cache-trivy + description: >- + Name (key) where Trivy data should be stored in the GitHub cache + + scan-target: + default: . + description: >- + What Trivy should scan + + scan-type: + default: repository + description: >- + How Trivy should interpret scan-target; one of filesystem, image, repository, or sbom. + +runs: + using: composite + steps: + # Parse list inputs as separated by commas and spaces. + # Select the maximum version-looking string from `inputs.setup`. + - id: parsed + shell: bash + run: | + # Validate inputs + ( + <<< '${{ inputs.cache }}' jq -rRsS '"cache=\(split("[,\\s]+"; "") - [""])"' + <<< '${{ inputs.setup }}' jq -rRsS ' + "setup=\(split("[,\\s]+"; "") - [""])", + "version=\(split("[,\\s]+"; "") | max_by(split("[v.]"; "") | map(tonumber?)))" + ' + ) | tee --append "${GITHUB_OUTPUT}" + + # Install Trivy as requested. + # NOTE: `setup-trivy` can download a "latest" version but cannot cache it. + - if: ${{ ! contains(fromJSON(steps.parsed.outputs.setup), 'none') }} + uses: aquasecurity/setup-trivy@v0.2.3 + with: + cache: ${{ contains(fromJSON(steps.parsed.outputs.setup), 'cache') }} + version: ${{ steps.parsed.outputs.version }} + + # Restore a recent cache beginning with the prefix. + - id: restore + if: ${{ contains(fromJSON(steps.parsed.outputs.cache), 'restore') }} + uses: actions/cache/restore@v4 + with: + path: ${{ inputs.cache-directory }} + key: ${{ inputs.cache-prefix }}- + + - id: trivy + shell: bash + env: + TRIVY_CACHE_DIR: >- + ${{ contains(fromJSON(steps.parsed.outputs.cache), 'use') && inputs.cache-directory || '' }} + TRIVY_SKIP_CHECK_UPDATE: ${{ inputs.database == 'skip' }} + TRIVY_SKIP_DB_UPDATE: ${{ inputs.database == 'skip' }} + TRIVY_SKIP_JAVA_DB_UPDATE: ${{ inputs.database == 'skip' }} + TRIVY_SKIP_VEX_REPO_UPDATE: ${{ inputs.database == 'skip' }} + run: | + # Run Trivy + trivy '${{ inputs.scan-type }}' '${{ inputs.scan-target }}' || result=$? + + checksum=$([[ -z "${TRIVY_CACHE_DIR}" ]] || cat "${TRIVY_CACHE_DIR}/"*/metadata.json | sha256sum) + echo 'cache-key=${{ inputs.cache-prefix }}-'"${checksum%% *}" >> "${GITHUB_OUTPUT}" + + exit "${result-0}" + + # Save updated data to the cache when requested. + - if: >- + ${{ + steps.restore.outcome == 'success' && + steps.restore.outputs.cache-matched-key == steps.trivy.outputs.cache-key + }} + shell: bash + run: | + # Cache hit on ${{ steps.restore.outputs.cache-matched-key }} + - if: >- + ${{ + steps.restore.outputs.cache-matched-key != steps.trivy.outputs.cache-key && + ( + (contains(fromJSON(steps.parsed.outputs.cache), 'save') && !cancelled()) || + (contains(fromJSON(steps.parsed.outputs.cache), 'success') && success()) + ) + }} + uses: actions/cache/save@v4 + with: + key: ${{ steps.trivy.outputs.cache-key }} + path: ${{ inputs.cache-directory }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 639a059edc..8a16fc8d6f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,63 @@ -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/customizing-dependency-updates +# Copyright 2024 - 2025 Crunchy Data Solutions, Inc. # -# See: https://www.github.com/dependabot/dependabot-core/issues/4605 ---- +# SPDX-License-Identifier: Apache-2.0 +# +# documentation: https://docs.github.com/code-security/dependabot/dependabot-version-updates +# schema documentation: https://docs.github.com/code-security/dependabot/working-with-dependabot/dependabot-options-reference # yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json +# +# Dependabot allows only one schedule per package-ecosystem, directory, and target-branch. +# Configurations that lack a "target-branch" field also affect security updates. +# +# There is a hack to have *two* schedules: https://github.com/dependabot/dependabot-core/issues/1778#issuecomment-1988140219 +--- version: 2 + updates: - package-ecosystem: github-actions - directory: / + directories: + # "/" is a special case that includes ".github/workflows/*" + - '/' + - '.github/actions/*' + registries: '*' schedule: interval: weekly day: tuesday + labels: + - dependencies groups: - all-github-actions: + # Group security updates into one pull request + action-vulnerabilities: + applies-to: security-updates + patterns: ['*'] + + # Group version updates into one pull request + github-actions: + applies-to: version-updates + patterns: ['*'] + + - package-ecosystem: gomod + directory: '/' + registries: '*' + schedule: + interval: weekly + day: wednesday + labels: + - dependencies + groups: + # Group security updates into one pull request + go-vulnerabilities: + applies-to: security-updates + patterns: ['*'] + + # Group Kubernetes and OpenTelemetry version updates into separate pull requests + kubernetes: + patterns: ['k8s.io/*', 'sigs.k8s.io/*'] + opentelemetry: + patterns: ['go.opentelemetry.io/*'] + go-dependencies: patterns: ['*'] + exclude-patterns: + - 'k8s.io/*' + - 'sigs.k8s.io/*' + - 'go.opentelemetry.io/*' diff --git a/.github/workflows/codeql-analysis.yaml b/.github/workflows/codeql-analysis.yaml index ae4d24d122..b0e06b4d02 100644 --- a/.github/workflows/codeql-analysis.yaml +++ b/.github/workflows/codeql-analysis.yaml @@ -1,3 +1,4 @@ +# https://codeql.github.com name: CodeQL on: @@ -15,16 +16,15 @@ env: jobs: analyze: - runs-on: ubuntu-latest + if: ${{ github.repository == 'CrunchyData/postgres-operator' }} permissions: actions: read contents: read security-events: write - if: ${{ github.repository == 'CrunchyData/postgres-operator' }} - + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: { go-version: stable } diff --git a/.github/workflows/govulncheck.yaml b/.github/workflows/govulncheck.yaml new file mode 100644 index 0000000000..656641b5da --- /dev/null +++ b/.github/workflows/govulncheck.yaml @@ -0,0 +1,46 @@ +# https://go.dev/security/vuln +name: govulncheck + +on: + pull_request: + push: + branches: + - main + +env: + # Use the Go toolchain installed by setup-go + # https://github.com/actions/setup-go/issues/457 + GOTOOLCHAIN: local + +jobs: + vulnerabilities: + if: ${{ github.repository == 'CrunchyData/postgres-operator' }} + permissions: + security-events: write + + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + + # Install Go and produce a SARIF report. This fails only when the tool is + # unable to scan. + - name: Prepare report + uses: golang/govulncheck-action@v1 + with: + output-file: 'govulncheck-results.sarif' + output-format: 'sarif' + repo-checkout: false + + # Submit the SARIF report to GitHub code scanning. Pull request checks + # succeed or fail according to branch protection rules. + # - https://docs.github.com/en/code-security/code-scanning + - name: Upload results to GitHub + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'govulncheck-results.sarif' + + # Print any detected vulnerabilities to the workflow log. This step fails + # when the tool detects a vulnerability in code that is called. + # - https://go.dev/blog/govulncheck + - name: Log results + run: govulncheck --format text --show verbose ./... diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index c715f2a1d7..9f97c4d92c 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -10,16 +10,16 @@ env: jobs: golangci-lint: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read checks: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: { go-version: stable } - - uses: golangci/golangci-lint-action@v6 + - uses: golangci/golangci-lint-action@v8 with: version: latest args: --timeout=5m @@ -28,12 +28,8 @@ jobs: # exits zero to ensure it does not fail the pull request check. - name: Count non-blocking issues run: | - golangci-lint run --config .golangci.next.yaml \ - --issues-exit-code 0 \ - --max-issues-per-linter 0 \ - --max-same-issues 0 \ - --out-format json | - jq --sort-keys 'reduce .Issues[] as $i ({}; .[$i.FromLinter] += 1)' | - awk >> "${GITHUB_STEP_SUMMARY}" ' - NR == 1 { print "```json" } { print } END { if (NR > 0) print "```" } - ' || true + golangci-lint run --config .golangci.next.yaml --show-stats >> "${GITHUB_STEP_SUMMARY}" \ + --max-issues-per-linter=0 \ + --max-same-issues=0 \ + --uniq-by-line=0 \ + --output.text.path=/dev/null ||: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e8174e4f95..75574b6c7a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -13,28 +13,30 @@ env: jobs: go-test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: { go-version: stable } - - run: make check - - run: make check-generate - name: Ensure go.mod is tidy run: go mod tidy && git diff --exit-code -- go.mod + - name: Ensure generated files are committed + run: make check-generate + - run: make check kubernetes-api: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [go-test] strategy: fail-fast: false matrix: kubernetes: ['default'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: { go-version: stable } + - run: go mod download - run: ENVTEST_K8S_VERSION="${KUBERNETES#default}" make check-envtest env: @@ -51,14 +53,14 @@ jobs: kubernetes-k3d: if: "${{ github.repository == 'CrunchyData/postgres-operator' }}" - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [go-test] strategy: fail-fast: false matrix: - kubernetes: [v1.31, v1.28] + kubernetes: [v1.33, v1.28] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: { go-version: stable } @@ -67,9 +69,9 @@ jobs: with: k3s-channel: "${{ matrix.kubernetes }}" prefetch-images: | - registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi8-2.53.1-0 - registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi8-1.23-0 - registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.4-2 + registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi9-2.54.2-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi9-1.24-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-16.9-2520 - run: make createnamespaces check-envtest-existing env: @@ -84,15 +86,15 @@ jobs: path: envtest-existing.coverage.gz retention-days: 1 - kuttl-k3d: - runs-on: ubuntu-latest + e2e-k3d: + runs-on: ubuntu-24.04 needs: [go-test] strategy: fail-fast: false matrix: - kubernetes: [v1.31, v1.30, v1.29, v1.28] + kubernetes: [v1.33, v1.28] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: { go-version: stable } @@ -101,19 +103,15 @@ jobs: with: k3s-channel: "${{ matrix.kubernetes }}" prefetch-images: | - registry.developers.crunchydata.com/crunchydata/crunchy-pgadmin4:ubi8-4.30-31 - registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi8-2.53.1-0 - registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi8-1.23-0 - registry.developers.crunchydata.com/crunchydata/crunchy-postgres-exporter:latest - registry.developers.crunchydata.com/crunchydata/crunchy-upgrade:latest - registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.4-2 - registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-16.4-3.3-2 - registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-16.4-3.4-2 - registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-17.0-0 - registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-17.0-3.4-0 - - run: go mod download - - name: Build executable - run: PGO_VERSION='${{ github.sha }}' make build-postgres-operator + registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi9-2.54.2-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi9-1.24-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-postgres-exporter:ubi9-0.17.1-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-17.5-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-16.9-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-16.9-3.3-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-16.9-3.4-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-upgrade:ubi9-17.5-2520 + registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-17.5-3.4-2520 - name: Get pgMonitor files. run: make get-pgmonitor @@ -122,34 +120,36 @@ jobs: QUERIES_CONFIG_DIR: "${{ github.workspace }}/hack/tools/queries" # Start a Docker container with the working directory mounted. + - run: make build BUILDAH=docker - name: Start PGO run: | kubectl apply --server-side -k ./config/namespace kubectl apply --server-side -k ./config/dev hack/create-kubeconfig.sh postgres-operator pgo docker run --detach --network host --read-only \ - --volume "$(pwd):/mnt" --workdir '/mnt' --env 'PATH=/mnt/bin' \ + --volume "$(pwd):/mnt" --workdir '/mnt' \ --env 'CHECK_FOR_UPGRADES=false' \ --env 'QUERIES_CONFIG_DIR=/mnt/hack/tools/queries' \ --env 'KUBECONFIG=hack/.kube/postgres-operator/pgo' \ - --env 'RELATED_IMAGE_PGADMIN=registry.developers.crunchydata.com/crunchydata/crunchy-pgadmin4:ubi8-4.30-31' \ - --env 'RELATED_IMAGE_PGBACKREST=registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi8-2.53.1-0' \ - --env 'RELATED_IMAGE_PGBOUNCER=registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi8-1.23-0' \ - --env 'RELATED_IMAGE_PGEXPORTER=registry.developers.crunchydata.com/crunchydata/crunchy-postgres-exporter:latest' \ - --env 'RELATED_IMAGE_PGUPGRADE=registry.developers.crunchydata.com/crunchydata/crunchy-upgrade:latest' \ - --env 'RELATED_IMAGE_POSTGRES_16=registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.4-2' \ - --env 'RELATED_IMAGE_POSTGRES_16_GIS_3.3=registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-16.4-3.3-2' \ - --env 'RELATED_IMAGE_POSTGRES_16_GIS_3.4=registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-16.4-3.4-2' \ - --env 'RELATED_IMAGE_POSTGRES_17=registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-17.0-0' \ - --env 'RELATED_IMAGE_POSTGRES_17_GIS_3.4=registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-17.0-3.4-0' \ - --env 'RELATED_IMAGE_STANDALONE_PGADMIN=registry.developers.crunchydata.com/crunchydata/crunchy-pgadmin4:ubi8-8.12-0' \ - --env 'PGO_FEATURE_GATES=TablespaceVolumes=true' \ - --name 'postgres-operator' ubuntu \ - postgres-operator - - name: Install kuttl - run: | - curl -Lo /usr/local/bin/kubectl-kuttl https://github.com/kudobuilder/kuttl/releases/download/v0.13.0/kubectl-kuttl_0.13.0_linux_x86_64 - chmod +x /usr/local/bin/kubectl-kuttl + --env 'RELATED_IMAGE_PGBACKREST=registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi9-2.54.2-2520' \ + --env 'RELATED_IMAGE_PGBOUNCER=registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi9-1.24-2520' \ + --env 'RELATED_IMAGE_PGEXPORTER=registry.developers.crunchydata.com/crunchydata/crunchy-postgres-exporter:ubi9-0.17.1-2520' \ + --env 'RELATED_IMAGE_PGUPGRADE=registry.developers.crunchydata.com/crunchydata/crunchy-upgrade:ubi9-17.5-2520' \ + --env 'RELATED_IMAGE_POSTGRES_16=registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-16.9-2520' \ + --env 'RELATED_IMAGE_POSTGRES_16_GIS_3.3=registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-16.9-3.3-2520' \ + --env 'RELATED_IMAGE_POSTGRES_16_GIS_3.4=registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-16.9-3.4-2520' \ + --env 'RELATED_IMAGE_POSTGRES_17=registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-17.5-2520' \ + --env 'RELATED_IMAGE_POSTGRES_17_GIS_3.4=registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-17.5-3.4-2520' \ + --env 'RELATED_IMAGE_STANDALONE_PGADMIN=registry.developers.crunchydata.com/crunchydata/crunchy-pgadmin4:ubi9-9.2-2520' \ + --env 'RELATED_IMAGE_COLLECTOR=registry.developers.crunchydata.com/crunchydata/postgres-operator:ubi9-5.8.2-0' \ + --env 'PGO_FEATURE_GATES=TablespaceVolumes=true,OpenTelemetryLogs=true,OpenTelemetryMetrics=true' \ + --name 'postgres-operator' localhost/postgres-operator + + - run: | + make check-chainsaw && exit + failed=$? + echo '::group::PGO logs'; docker logs 'postgres-operator'; echo '::endgroup::' + exit $failed - run: make generate-kuttl env: @@ -157,28 +157,27 @@ jobs: KUTTL_PG_UPGRADE_TO_VERSION: '17' KUTTL_PG_VERSION: '16' KUTTL_POSTGIS_VERSION: '3.4' - KUTTL_PSQL_IMAGE: 'registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.4-2' + KUTTL_PSQL_IMAGE: 'registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-16.9-2520' - run: | make check-kuttl && exit failed=$? echo '::group::PGO logs'; docker logs 'postgres-operator'; echo '::endgroup::' exit $failed - env: - KUTTL_TEST: kubectl-kuttl test + - name: Stop PGO run: docker stop 'postgres-operator' || true coverage-report: if: ${{ success() || contains(needs.*.result, 'success') }} - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: - kubernetes-api - kubernetes-k3d steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-go@v5 with: { go-version: stable } - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v5 with: { path: download } # Combine the coverage profiles by taking the mode line from any one file diff --git a/.github/workflows/trivy.yaml b/.github/workflows/trivy.yaml index 2a16e4929c..1ae2230874 100644 --- a/.github/workflows/trivy.yaml +++ b/.github/workflows/trivy.yaml @@ -1,3 +1,4 @@ +# https://aquasecurity.github.io/trivy name: Trivy on: @@ -11,65 +12,118 @@ env: # https://github.com/actions/setup-go/issues/457 GOTOOLCHAIN: local + # Use the committed Trivy configuration files. + TRIVY_IGNOREFILE: .trivyignore.yaml + TRIVY_SECRET_CONFIG: trivy-secret.yaml + jobs: + cache: + # Run only one of these jobs at a time across the entire project. + concurrency: { group: trivy-cache } + # Do not fail this workflow when this job fails. + continue-on-error: true + + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - name: Download Trivy + uses: ./.github/actions/trivy + env: + TRIVY_DEBUG: true + TRIVY_DOWNLOAD_DB_ONLY: true + TRIVY_NO_PROGRESS: true + TRIVY_SCANNERS: license,secret,vuln + with: + cache: restore,success,use + database: update + licenses: - runs-on: ubuntu-latest + # Run this job after the cache job regardless of its success or failure. + needs: [cache] + if: >- + ${{ !cancelled() }} + + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 # Trivy needs a populated Go module cache to detect Go module licenses. - uses: actions/setup-go@v5 with: { go-version: stable } - run: go mod download - # Report success only when detected licenses are listed in [/trivy.yaml]. + # Report success only when detected licenses are listed in [.trivyignore.yaml]. - name: Scan licenses - uses: aquasecurity/trivy-action@0.28.0 + uses: ./.github/actions/trivy env: TRIVY_DEBUG: true + TRIVY_EXIT_CODE: 1 + TRIVY_SCANNERS: license with: - scan-type: filesystem - scanners: license - exit-code: 1 + cache: restore,use + database: skip - vulnerabilities: - if: ${{ github.repository == 'CrunchyData/postgres-operator' }} + secrets: + # Run this job after the cache job regardless of its success or failure. + needs: [cache] + if: >- + ${{ !cancelled() }} - permissions: - # for github/codeql-action/upload-sarif to upload SARIF results - security-events: write + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 - runs-on: ubuntu-latest + # Report success only when detected secrets are listed in [.trivyignore.yaml]. + - name: Scan secrets + uses: ./.github/actions/trivy + env: + TRIVY_EXIT_CODE: 1 + TRIVY_SCANNERS: secret + with: + cache: restore,use + database: skip + + vulnerabilities: + # Run this job after the cache job regardless of its success or failure. + needs: [cache] + if: >- + ${{ github.repository == 'CrunchyData/postgres-operator' && !cancelled() }} + permissions: + security-events: write + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - # Run trivy and log detected and fixed vulnerabilities - # This report should match the uploaded code scan report below - # and is a convenience/redundant effort for those who prefer to - # read logs and/or if anything goes wrong with the upload. - - name: Log all detected vulnerabilities - uses: aquasecurity/trivy-action@0.28.0 + - uses: actions/checkout@v5 + + # Print any detected secrets or vulnerabilities to the workflow log for + # human consumption. This step fails only when Trivy is unable to scan. + # A later step uploads results to GitHub as a pull request check. + - name: Log detected vulnerabilities + uses: ./.github/actions/trivy + env: + TRIVY_SCANNERS: secret,vuln with: - scan-type: filesystem - hide-progress: true - ignore-unfixed: true - scanners: secret,vuln - - # Upload actionable results to the GitHub Security tab. - # Pull request checks fail according to repository settings. - # - https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github - # - https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning + cache: restore,use + database: skip + + # Produce a SARIF report of actionable results. This step fails only when + # Trivy is unable to scan. - name: Report actionable vulnerabilities - uses: aquasecurity/trivy-action@0.28.0 + uses: ./.github/actions/trivy + env: + TRIVY_IGNORE_UNFIXED: true + TRIVY_FORMAT: 'sarif' + TRIVY_OUTPUT: 'trivy-results.sarif' + TRIVY_SCANNERS: secret,vuln with: - scan-type: filesystem - ignore-unfixed: true - format: 'sarif' - output: 'trivy-results.sarif' - scanners: secret,vuln + cache: use + database: skip + setup: none - - name: Upload Trivy scan results to GitHub Security tab + # Submit the SARIF report to GitHub code scanning. Pull requests checks + # succeed or fail according to branch protection rules. + # - https://docs.github.com/en/code-security/code-scanning + - name: Upload results to GitHub uses: github/codeql-action/upload-sarif@v3 with: sarif_file: 'trivy-results.sarif' diff --git a/.golangci.next.yaml b/.golangci.next.yaml index 95b3f63347..f35c9265d8 100644 --- a/.golangci.next.yaml +++ b/.golangci.next.yaml @@ -1,40 +1,98 @@ -# https://golangci-lint.run/usage/configuration/ +# https://golangci-lint.run/usage/configuration # # This file is for linters that might be interesting to enforce in the future. # Rules that should be enforced immediately belong in [.golangci.yaml]. # # Both files are used by [.github/workflows/lint.yaml]. +version: "2" +# https://golangci-lint.run/usage/linters linters: - disable-all: true - enable: - - contextcheck - - err113 + default: all + disable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - copyloopvar + - depguard + - dupword + - durationcheck - errchkjson - - gocritic - - godot - - godox - - gofumpt - - gosec # exclude-use-default - - nilnil + - errname + - errorlint + - exhaustive + - exptostd + - fatcontext + - forbidigo + - ginkgolinter + - gocheckcompilerdirectives + - gochecksumtype + - goheader + - gomoddirectives + - gomodguard + - goprintffuncname + - gosmopolitan + - grouper + - iface + - importas + - interfacebloat + - intrange + - loggercheck + - makezero + - mirror + - misspell + - musttag + - nilerr + - nilnesserr + - noctx - nolintlint - - predeclared - - revive - - staticcheck # exclude-use-default - - tenv - - thelper - - tparallel + - nosprintfhostport + - prealloc + - promlinter + - protogetter + - reassign + - recvcheck + - rowserrcheck + - sloglint + - spancheck + - sqlclosecheck + - tagalign + - testifylint + - unconvert + - unparam + - usestdlibvars + - usetesting - wastedassign + - wsl + - zerologlint -issues: - # https://github.com/golangci/golangci-lint/issues/2239 - exclude-use-default: false + settings: + thelper: + # https://github.com/kulti/thelper/issues/27 + tb: { begin: true, first: true } + test: { begin: true, first: true, name: true } + + exclusions: + warn-unused: true + # Ignore built-in exclusions + presets: [] + rules: + # We call external linters when they are installed: Flake8, ShellCheck, etc. + - linters: [gosec] + path: '_test[.]go$' + text: 'G204: Subprocess launched with variable' -linters-settings: - errchkjson: - check-error-free-encoding: true +# https://golangci-lint.run/usage/formatters +formatters: + enable: + - gofumpt + +issues: + # Fix only when requested + fix: false - thelper: - # https://github.com/kulti/thelper/issues/27 - tb: { begin: true, first: true } - test: { begin: true, first: true, name: true } + # Show all issues at once + max-issues-per-linter: 0 + max-same-issues: 0 + uniq-by-line: false diff --git a/.golangci.yaml b/.golangci.yaml index 87a6ed0464..a1de0813b4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,87 +1,192 @@ -# https://golangci-lint.run/usage/configuration/ +# https://golangci-lint.run/usage/configuration +version: "2" +# https://golangci-lint.run/usage/linters linters: - disable: - - contextcheck - - errchkjson - - gci - - gofumpt + default: standard enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - copyloopvar - depguard + - dupword + - durationcheck + - errchkjson + - errname + - errorlint + - exhaustive + - exptostd + - fatcontext + - forbidigo + - ginkgolinter + - gocheckcompilerdirectives + - gochecksumtype - goheader + - gomoddirectives - gomodguard - - gosimple + - goprintffuncname + - gosec + - gosmopolitan + - grouper + - iface - importas + - interfacebloat + - intrange + - loggercheck + - makezero + - mirror - misspell + - musttag + - nilerr + - nilnesserr + - noctx + - nolintlint + - nosprintfhostport + - prealloc + - promlinter + - protogetter + - reassign + - recvcheck + - rowserrcheck + - sloglint + - spancheck + - sqlclosecheck + - tagalign + - testifylint - unconvert - presets: - - bugs - - format - - unused + - unparam + - usestdlibvars + - usetesting + - wastedassign + - zerologlint + + settings: + depguard: + rules: + everything: + files: ['$all'] + list-mode: lax + allow: + - go.opentelemetry.io/otel/semconv/v1.27.0 + deny: + - pkg: go.opentelemetry.io/otel/semconv + desc: Use "go.opentelemetry.io/otel/semconv/v1.27.0" instead. + - pkg: io/ioutil + desc: Use the "io" and "os" packages instead. See https://go.dev/doc/go1.16#ioutil + - pkg: math/rand$ + desc: Use the "math/rand/v2" package instead. See https://go.dev/doc/go1.22#math_rand_v2 + not-tests: + files: ['!$test','!**/internal/testing/**'] + list-mode: lax + deny: + - pkg: net/http/httptest + desc: Should be used only in tests. + - pkg: testing/* + desc: The "testing" packages should be used only in tests. + - pkg: github.com/crunchydata/postgres-operator/internal/testing/* + desc: The "internal/testing" packages should be used only in tests. + - pkg: k8s.io/client-go/discovery + desc: Use the "internal/kubernetes" package instead. + tests: + files: ['$test'] + list-mode: lax + deny: + - pkg: github.com/pkg/errors + desc: Use the "errors" package unless you are interacting with stack traces. + + errchkjson: + check-error-free-encoding: true + + goheader: + template: |- + Copyright {{ DATES }} Crunchy Data Solutions, Inc. + + SPDX-License-Identifier: Apache-2.0 + values: + regexp: + DATES: ((201[7-9]|202[0-4]) - 2025|2025) + + gomodguard: + blocked: + modules: + - gopkg.in/yaml.v2: { recommendations: [sigs.k8s.io/yaml] } + - gopkg.in/yaml.v3: { recommendations: [sigs.k8s.io/yaml] } + - gotest.tools: { recommendations: [gotest.tools/v3] } + - k8s.io/kubernetes: + reason: k8s.io/kubernetes is for building kubelet, kubeadm, etc. + + importas: + no-unaliased: true + alias: + - pkg: k8s.io/api/(\w+)/(v[\w\w]+) + alias: $1$2 + - pkg: k8s.io/apimachinery/pkg/apis/(\w+)/(v[\w\d]+) + alias: $1$2 + - pkg: k8s.io/apimachinery/pkg/api/errors + alias: apierrors + + spancheck: + checks: [end, record-error] + extra-start-span-signatures: + - github.com/crunchydata/postgres-operator/internal/tracing.Start:opentelemetry + ignore-check-signatures: + - tracing.Escape -linters-settings: - depguard: + exclusions: + warn-unused: true + presets: + - common-false-positives + - legacy + - std-error-handling rules: - everything: - deny: - - pkg: io/ioutil - desc: > - Use the "io" and "os" packages instead. - See https://go.dev/doc/go1.16#ioutil - - not-tests: - files: ['!$test'] - deny: - - pkg: net/http/httptest - desc: Should be used only in tests. - - - pkg: testing/* - desc: The "testing" packages should be used only in tests. - - - pkg: github.com/crunchydata/postgres-operator/internal/testing/* - desc: The "internal/testing" packages should be used only in tests. - - exhaustive: - default-signifies-exhaustive: true - - goheader: - template: |- - Copyright {{ DATES }} Crunchy Data Solutions, Inc. - - SPDX-License-Identifier: Apache-2.0 - values: - regexp: - DATES: '((201[7-9]|202[0-3]) - 2024|2024)' - - goimports: - local-prefixes: github.com/crunchydata/postgres-operator - - gomodguard: - blocked: - modules: - - gopkg.in/yaml.v2: { recommendations: [sigs.k8s.io/yaml] } - - gopkg.in/yaml.v3: { recommendations: [sigs.k8s.io/yaml] } - - gotest.tools: { recommendations: [gotest.tools/v3] } - - k8s.io/kubernetes: - reason: > - k8s.io/kubernetes is for managing dependencies of the Kubernetes - project, i.e. building kubelet and kubeadm. - - gosec: - excludes: - # Flags for potentially-unsafe casting of ints, similar problem to globally-disabled G103 - - G115 - - importas: - alias: - - pkg: k8s.io/api/(\w+)/(v[\w\w]+) - alias: $1$2 - - pkg: k8s.io/apimachinery/pkg/apis/(\w+)/(v[\w\d]+) - alias: $1$2 - - pkg: k8s.io/apimachinery/pkg/api/errors - alias: apierrors - no-unaliased: true + # It is fine for tests to use "math/rand" packages. + - linters: [gosec] + path: '(.+)_test[.]go' + text: weak random number generator + + # This internal package is the one place we want to do API discovery. + - linters: [depguard] + path: internal/kubernetes/discovery.go + text: k8s.io/client-go/discovery + + # Postgres HBA rules often include "all all all" + - linters: [dupword] + path: /(hba|postgres)[^/]+$ + text: words \(all\) found + + # These value types have unmarshal methods. + # https://github.com/raeperd/recvcheck/issues/7 + - linters: [recvcheck] + path: internal/pki/pki.go + text: methods of "(Certificate|PrivateKey)" + + - linters: [staticcheck] + text: corev1.(Endpoints|EndpointSubset) is deprecated + + - linters: [staticcheck] + path: internal/controller/ + text: >- + deprecated: Use `RequeueAfter` instead + +# https://golangci-lint.run/usage/formatters +formatters: + enable: + - gci + - gofmt + settings: + gci: + sections: + - standard + - default + - localmodule issues: - exclude-dirs: - - pkg/generated + # Fix only when requested + fix: false + + # Show all issues at once + max-issues-per-linter: 0 + max-same-issues: 0 + uniq-by-line: false diff --git a/.trivyignore.yaml b/.trivyignore.yaml new file mode 100644 index 0000000000..96d0e4e789 --- /dev/null +++ b/.trivyignore.yaml @@ -0,0 +1,29 @@ +# Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +# https://trivy.dev/latest/docs/configuration/filtering#trivyignoreyaml + +# Specify an exact list of recognized and acceptable licenses. +# [A GitHub workflow](.github/workflows/trivy.yaml) rejects pull requests that import licenses not in this list. +# +# https://trivy.dev/latest/docs/scanner/license +licenses: + - id: Apache-2.0 + - id: BSD-2-Clause + - id: BSD-3-Clause + - id: ISC + - id: MIT + +# These values are used for testing and are not secret. +# [A GitHub workflow](.github/workflows/trivy.yaml) rejects pull requests that contain secrets not in this list. +# +# https://trivy.dev/latest/docs/scanner/secret +secrets: + - id: jwt-token + paths: + - internal/testing/token_* + + - id: private-key + paths: + - internal/pki/*_test.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..a218dfe492 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Copyright 2017 - 2025 Crunchy Data Solutions, Inc. +# +# SPDX-License-Identifier: Apache-2.0 + +FROM docker.io/library/golang:bookworm AS build + +COPY licenses /licenses +COPY hack/tools/queries /opt/crunchy/conf + +WORKDIR /usr/src/app +COPY . . +ENV GOCACHE=/var/cache/go +RUN --mount=type=cache,target=/var/cache/go go build ./cmd/postgres-operator + +FROM docker.io/library/debian:bookworm + +COPY --from=build /licenses /licenses +COPY --from=build /opt/crunchy/conf /opt/crunchy/conf +COPY --from=build /usr/src/app/postgres-operator /usr/local/bin + +USER 2 + +CMD ["postgres-operator"] diff --git a/LICENSE.md b/LICENSE.md index 8d57ad6f2e..3960704149 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -176,7 +176,7 @@ END OF TERMS AND CONDITIONS - Copyright 2017 - 2024 Crunchy Data Solutions, Inc. + Copyright 2017 - 2025 Crunchy Data Solutions, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/Makefile b/Makefile index 37aca1a37e..680884e0f8 100644 --- a/Makefile +++ b/Makefile @@ -1,47 +1,29 @@ -PGO_IMAGE_NAME ?= postgres-operator -PGO_IMAGE_MAINTAINER ?= Crunchy Data -PGO_IMAGE_SUMMARY ?= Crunchy PostgreSQL Operator -PGO_IMAGE_DESCRIPTION ?= $(PGO_IMAGE_SUMMARY) -PGO_IMAGE_URL ?= https://www.crunchydata.com/products/crunchy-postgresql-for-kubernetes -PGO_IMAGE_PREFIX ?= localhost PGMONITOR_DIR ?= hack/tools/pgmonitor -PGMONITOR_VERSION ?= v5.1.1 +PGMONITOR_VERSION ?= v5.2.1 QUERIES_CONFIG_DIR ?= hack/tools/queries -EXTERNAL_SNAPSHOTTER_DIR ?= hack/tools/external-snapshotter -EXTERNAL_SNAPSHOTTER_VERSION ?= v8.0.1 - -# Buildah's "build" used to be "bud". Use the alias to be compatible for a while. -BUILDAH_BUILD ?= buildah bud - +BUILDAH ?= buildah GO ?= go -GO_BUILD = $(GO) build GO_TEST ?= $(GO) test -KUTTL ?= kubectl-kuttl + +# Ensure modules imported by `postgres-operator` and `controller-gen` are compatible +# by managing them together in the main module. +CONTROLLER ?= $(GO) tool sigs.k8s.io/controller-tools/cmd/controller-gen + +# Run tests using the latest tools. +CHAINSAW ?= $(GO) run github.com/kyverno/chainsaw@latest +CHAINSAW_TEST ?= $(CHAINSAW) test +ENVTEST ?= $(GO) run sigs.k8s.io/controller-runtime/tools/setup-envtest@latest +KUTTL ?= $(GO) run github.com/kudobuilder/kuttl/cmd/kubectl-kuttl@latest KUTTL_TEST ?= $(KUTTL) test ##@ General -# The help target prints out all targets with their descriptions organized -# beneath their categories. The categories are represented by '##@' and the -# target descriptions by '##'. The awk command is responsible for reading the -# entire set of makefiles included in this invocation, looking for lines of the -# file as xyz: ## something, and then pretty-formatting the target and help. Then, -# if there's a line with ##@ something, that gets pretty-printed as a category. -# More info on the usage of ANSI control characters for terminal formatting: -# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters -# More info on the awk command: -# http://linuxcommand.org/lc3_adv_awk.php - .PHONY: help help: ## Display this help. @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) -.PHONY: all -all: ## Build all images -all: build-postgres-operator-image - .PHONY: setup setup: ## Run Setup needed to build images setup: get-pgmonitor @@ -55,11 +37,9 @@ get-pgmonitor: cp -r '$(PGMONITOR_DIR)/postgres_exporter/common/.' '${QUERIES_CONFIG_DIR}' cp '$(PGMONITOR_DIR)/postgres_exporter/linux/queries_backrest.yml' '${QUERIES_CONFIG_DIR}' -.PHONY: get-external-snapshotter -get-external-snapshotter: - git -C '$(dir $(EXTERNAL_SNAPSHOTTER_DIR))' clone https://github.com/kubernetes-csi/external-snapshotter.git || git -C '$(EXTERNAL_SNAPSHOTTER_DIR)' fetch origin - @git -C '$(EXTERNAL_SNAPSHOTTER_DIR)' checkout '$(EXTERNAL_SNAPSHOTTER_VERSION)' - @git -C '$(EXTERNAL_SNAPSHOTTER_DIR)' config pull.ff only +.PHONY: notes +notes: ## List known issues and future considerations + command -v rg > /dev/null && rg '(BUGS|FIXME|NOTE|TODO)[(][^)]+[)]' || grep -Ern '(BUGS|FIXME|NOTE|TODO)[(][^)]+[)]' * .PHONY: clean clean: ## Clean resources @@ -67,8 +47,6 @@ clean: clean-deprecated rm -f bin/postgres-operator rm -rf licenses/*/ [ ! -d testing/kuttl/e2e-generated ] || rm -r testing/kuttl/e2e-generated - [ ! -d testing/kuttl/e2e-generated-other ] || rm -r testing/kuttl/e2e-generated-other - [ ! -f hack/tools/setup-envtest ] || rm hack/tools/setup-envtest [ ! -d hack/tools/envtest ] || { chmod -R u+w hack/tools/envtest && rm -r hack/tools/envtest; } [ ! -d hack/tools/pgmonitor ] || rm -rf hack/tools/pgmonitor [ ! -d hack/tools/external-snapshotter ] || rm -rf hack/tools/external-snapshotter @@ -94,9 +72,15 @@ clean-deprecated: ## Clean deprecated resources [ ! -d build/crunchy-postgres-exporter ] || rm -r build/crunchy-postgres-exporter @# CRDs used to require patching [ ! -d build/crd ] || rm -r build/crd + @# Old testing directories + [ ! -d testing/kuttl/e2e-generated-other ] || rm -r testing/kuttl/e2e-generated-other + @# Tools used to be downloaded directly + [ ! -f hack/tools/controller-gen ] || rm hack/tools/controller-gen + [ ! -f hack/tools/setup-envtest ] || rm hack/tools/setup-envtest ##@ Deployment + .PHONY: createnamespaces createnamespaces: ## Create operator and target namespaces kubectl apply -k ./config/namespace @@ -123,9 +107,8 @@ undeploy: ## Undeploy the PostgreSQL Operator .PHONY: deploy-dev deploy-dev: ## Deploy the PostgreSQL Operator locally -deploy-dev: PGO_FEATURE_GATES ?= "TablespaceVolumes=true,VolumeSnapshots=true" +deploy-dev: PGO_FEATURE_GATES ?= "AllAlpha=true" deploy-dev: get-pgmonitor -deploy-dev: build-postgres-operator deploy-dev: createnamespaces kubectl apply --server-side -k ./config/dev hack/create-kubeconfig.sh postgres-operator pgo @@ -144,54 +127,22 @@ deploy-dev: createnamespaces /RELATED_IMAGE_/ { N; s,.*\(RELATED_[^[:space:]]*\).*value:[[:space:]]*\([^[:space:]]*\),\1="\2",; p; }; \ }') \ $(foreach v,$(filter RELATED_IMAGE_%,$(.VARIABLES)),$(v)="$($(v))") \ - bin/postgres-operator - -##@ Build - Binary -.PHONY: build-postgres-operator -build-postgres-operator: ## Build the postgres-operator binary - CGO_ENABLED=1 $(GO_BUILD) $(\ - ) --ldflags '-X "main.versionString=$(PGO_VERSION)"' $(\ - ) --trimpath -o bin/postgres-operator ./cmd/postgres-operator - -##@ Build - Images -.PHONY: build-postgres-operator-image -build-postgres-operator-image: ## Build the postgres-operator image -build-postgres-operator-image: PGO_IMAGE_REVISION := $(shell git rev-parse HEAD) -build-postgres-operator-image: PGO_IMAGE_TIMESTAMP := $(shell date -u +%FT%TZ) -build-postgres-operator-image: build-postgres-operator -build-postgres-operator-image: build/postgres-operator/Dockerfile - $(if $(shell (echo 'buildah version 1.24'; $(word 1,$(BUILDAH_BUILD)) --version) | sort -Vc 2>&1), \ - $(warning WARNING: old buildah does not invalidate its cache for changed labels: \ - https://github.com/containers/buildah/issues/3517)) - $(if $(IMAGE_TAG),, $(error missing IMAGE_TAG)) - $(strip $(BUILDAH_BUILD)) \ - --tag $(BUILDAH_TRANSPORT)$(PGO_IMAGE_PREFIX)/$(PGO_IMAGE_NAME):$(IMAGE_TAG) \ - --label name='$(PGO_IMAGE_NAME)' \ - --label build-date='$(PGO_IMAGE_TIMESTAMP)' \ - --label description='$(PGO_IMAGE_DESCRIPTION)' \ - --label maintainer='$(PGO_IMAGE_MAINTAINER)' \ - --label summary='$(PGO_IMAGE_SUMMARY)' \ - --label url='$(PGO_IMAGE_URL)' \ - --label vcs-ref='$(PGO_IMAGE_REVISION)' \ - --label vendor='$(PGO_IMAGE_MAINTAINER)' \ - --label io.k8s.display-name='$(PGO_IMAGE_NAME)' \ - --label io.k8s.description='$(PGO_IMAGE_DESCRIPTION)' \ - --label io.openshift.tags="postgresql,postgres,sql,nosql,crunchy" \ - --annotation org.opencontainers.image.authors='$(PGO_IMAGE_MAINTAINER)' \ - --annotation org.opencontainers.image.vendor='$(PGO_IMAGE_MAINTAINER)' \ - --annotation org.opencontainers.image.created='$(PGO_IMAGE_TIMESTAMP)' \ - --annotation org.opencontainers.image.description='$(PGO_IMAGE_DESCRIPTION)' \ - --annotation org.opencontainers.image.revision='$(PGO_IMAGE_REVISION)' \ - --annotation org.opencontainers.image.title='$(PGO_IMAGE_SUMMARY)' \ - --annotation org.opencontainers.image.url='$(PGO_IMAGE_URL)' \ - $(if $(PGO_VERSION),$(strip \ - --label release='$(PGO_VERSION)' \ - --label version='$(PGO_VERSION)' \ - --annotation org.opencontainers.image.version='$(PGO_VERSION)' \ - )) \ - --file $< --format docker --layers . + $(GO) run ./cmd/postgres-operator + +##@ Build + +.PHONY: build +build: ## Build a postgres-operator image + $(BUILDAH) build --tag localhost/postgres-operator \ + --label org.opencontainers.image.authors='Crunchy Data' \ + --label org.opencontainers.image.description='Crunchy PostgreSQL Operator' \ + --label org.opencontainers.image.revision='$(shell git rev-parse HEAD)' \ + --label org.opencontainers.image.source='https://github.com/CrunchyData/postgres-operator' \ + --label org.opencontainers.image.title='Crunchy PostgreSQL Operator' \ + . ##@ Test + .PHONY: check check: ## Run basic go tests with coverage output check: get-pgmonitor @@ -203,18 +154,18 @@ check: get-pgmonitor check-envtest: ## Run check using envtest and a mock kube api check-envtest: ENVTEST_USE = $(ENVTEST) --bin-dir=$(CURDIR)/hack/tools/envtest use $(ENVTEST_K8S_VERSION) check-envtest: SHELL = bash -check-envtest: get-pgmonitor tools/setup-envtest get-external-snapshotter +check-envtest: get-pgmonitor @$(ENVTEST_USE) --print=overview && echo source <($(ENVTEST_USE) --print=env) && PGO_NAMESPACE="postgres-operator" QUERIES_CONFIG_DIR="$(CURDIR)/${QUERIES_CONFIG_DIR}" \ $(GO_TEST) -count=1 -cover ./... # The "PGO_TEST_TIMEOUT_SCALE" environment variable (default: 1) can be set to a -# positive number that extends test timeouts. The following runs tests with +# positive number that extends test timeouts. The following runs tests with # timeouts that are 20% longer than normal: # make check-envtest-existing PGO_TEST_TIMEOUT_SCALE=1.2 .PHONY: check-envtest-existing check-envtest-existing: ## Run check using envtest and an existing kube api -check-envtest-existing: get-pgmonitor get-external-snapshotter +check-envtest-existing: get-pgmonitor check-envtest-existing: createnamespaces kubectl apply --server-side -k ./config/dev USE_EXISTING_CLUSTER=true PGO_NAMESPACE="postgres-operator" QUERIES_CONFIG_DIR="$(CURDIR)/${QUERIES_CONFIG_DIR}" \ @@ -222,6 +173,21 @@ check-envtest-existing: createnamespaces kubectl delete -k ./config/dev # Expects operator to be running +# +# Chainsaw runs with a single kubectl context named "chainsaw". +# If you experience `cluster "minikube" does not exist`, try `MINIKUBE_PROFILE=chainsaw`. +# +# https://kyverno.github.io/chainsaw/latest/operations/script#kubeconfig +# +.PHONY: check-chainsaw +check-chainsaw: + $(CHAINSAW_TEST) --config testing/chainsaw/e2e/config.yaml --values testing/chainsaw/e2e/values.yaml testing/chainsaw/e2e + +# Expects operator to be running +# +# KUTTL runs with a single kubectl context named "cluster". +# If you experience `cluster "minikube" does not exist`, try `MINIKUBE_PROFILE=cluster`. +# .PHONY: check-kuttl check-kuttl: ## Run kuttl end-to-end tests check-kuttl: ## example command: make check-kuttl KUTTL_TEST=' @@ -229,15 +195,14 @@ check-kuttl: ## example command: make check-kuttl KUTTL_TEST=' --config testing/kuttl/kuttl-test.yaml .PHONY: generate-kuttl -generate-kuttl: export KUTTL_PG_UPGRADE_FROM_VERSION ?= 15 -generate-kuttl: export KUTTL_PG_UPGRADE_TO_VERSION ?= 16 +generate-kuttl: export KUTTL_PG_UPGRADE_FROM_VERSION ?= 16 +generate-kuttl: export KUTTL_PG_UPGRADE_TO_VERSION ?= 17 generate-kuttl: export KUTTL_PG_VERSION ?= 16 generate-kuttl: export KUTTL_POSTGIS_VERSION ?= 3.4 -generate-kuttl: export KUTTL_PSQL_IMAGE ?= registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.3-1 +generate-kuttl: export KUTTL_PSQL_IMAGE ?= registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-17.5-2520 generate-kuttl: export KUTTL_TEST_DELETE_NAMESPACE ?= kuttl-test-delete-namespace generate-kuttl: ## Generate kuttl tests [ ! -d testing/kuttl/e2e-generated ] || rm -r testing/kuttl/e2e-generated - [ ! -d testing/kuttl/e2e-generated-other ] || rm -r testing/kuttl/e2e-generated-other bash -ceu ' \ case $(KUTTL_PG_VERSION) in \ 16 ) export KUTTL_BITNAMI_IMAGE_TAG=16.0.0-debian-11-r3 ;; \ @@ -254,83 +219,45 @@ generate-kuttl: ## Generate kuttl tests source="$${1}" target="$${1/e2e/e2e-generated}"; \ mkdir -p "$${target%/*}"; render < "$${source}" > "$${target}"; \ shift; \ - done' - testing/kuttl/e2e/*/*.yaml testing/kuttl/e2e-other/*/*.yaml testing/kuttl/e2e/*/*/*.yaml testing/kuttl/e2e-other/*/*/*.yaml + done' - testing/kuttl/e2e/*/*.yaml testing/kuttl/e2e/*/*/*.yaml ##@ Generate .PHONY: check-generate -check-generate: ## Check crd, deepcopy functions, and rbac generation -check-generate: generate-crd -check-generate: generate-deepcopy -check-generate: generate-rbac +check-generate: ## Check everything generated is also committed +check-generate: generate git diff --exit-code -- config/crd git diff --exit-code -- config/rbac + git diff --exit-code -- internal/collector git diff --exit-code -- pkg/apis .PHONY: generate -generate: ## Generate crd, deepcopy functions, and rbac +generate: ## Generate everything +generate: generate-collector generate: generate-crd generate: generate-deepcopy generate: generate-rbac .PHONY: generate-crd generate-crd: ## Generate Custom Resource Definitions (CRDs) -generate-crd: tools/controller-gen $(CONTROLLER) \ crd:crdVersions='v1' \ paths='./pkg/apis/...' \ output:dir='config/crd/bases' # {directory}/{group}_{plural}.yaml +.PHONY: generate-collector +generate-collector: ## Generate OTel Collector files + $(GO) generate ./internal/collector + .PHONY: generate-deepcopy generate-deepcopy: ## Generate DeepCopy functions -generate-deepcopy: tools/controller-gen $(CONTROLLER) \ object:headerFile='hack/boilerplate.go.txt' \ paths='./pkg/apis/postgres-operator.crunchydata.com/...' .PHONY: generate-rbac generate-rbac: ## Generate RBAC -generate-rbac: tools/controller-gen $(CONTROLLER) \ rbac:roleName='postgres-operator' \ paths='./cmd/...' paths='./internal/...' \ output:dir='config/rbac' # {directory}/role.yaml - -##@ Tools - -.PHONY: tools -tools: ## Download tools like controller-gen and kustomize if necessary. - -# go-get-tool will 'go install' any package $2 and install it to $1. -define go-get-tool -@[ -f '$(1)' ] || { echo Downloading '$(2)'; GOBIN='$(abspath $(dir $(1)))' $(GO) install '$(2)'; } -endef - -CONTROLLER ?= hack/tools/controller-gen -tools: tools/controller-gen -tools/controller-gen: - $(call go-get-tool,$(CONTROLLER),sigs.k8s.io/controller-tools/cmd/controller-gen@v0.16.4) - -ENVTEST ?= hack/tools/setup-envtest -tools: tools/setup-envtest -tools/setup-envtest: - $(call go-get-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest@latest) - -##@ Release - -.PHONY: license licenses -license: licenses -licenses: ## Aggregate license files - ./bin/license_aggregator.sh ./cmd/... - -.PHONY: release-postgres-operator-image release-postgres-operator-image-labels -release-postgres-operator-image: ## Build the postgres-operator image and all its prerequisites -release-postgres-operator-image: release-postgres-operator-image-labels -release-postgres-operator-image: licenses -release-postgres-operator-image: build-postgres-operator-image -release-postgres-operator-image-labels: - $(if $(PGO_IMAGE_DESCRIPTION),, $(error missing PGO_IMAGE_DESCRIPTION)) - $(if $(PGO_IMAGE_MAINTAINER),, $(error missing PGO_IMAGE_MAINTAINER)) - $(if $(PGO_IMAGE_NAME),, $(error missing PGO_IMAGE_NAME)) - $(if $(PGO_IMAGE_SUMMARY),, $(error missing PGO_IMAGE_SUMMARY)) - $(if $(PGO_VERSION),, $(error missing PGO_VERSION)) diff --git a/bin/license_aggregator.sh b/bin/license_aggregator.sh deleted file mode 100755 index 66f7284a97..0000000000 --- a/bin/license_aggregator.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash - -# Copyright 2021 - 2024 Crunchy Data Solutions, Inc. -# 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. - -set -eu - -# Inputs / outputs -IN_PACKAGES=("$@") -OUT_DIR=licenses - -# Clean up before we start our work -rm -rf ${OUT_DIR:?}/*/ - -# Download dependencies of the requested packages, excluding the main module. -# - https://golang.org/ref/mod#glos-main-module -module=$(go list -m) -modules=$(go list -deps -f '{{with .Module}}{{.Path}}{{"\t"}}{{.Dir}}{{end}}' "${IN_PACKAGES[@]}") -dependencies=$(grep -v "^${module}" <<< "${modules}") - -while IFS=$'\t' read -r module directory; do - licenses=$(find "${directory}" -type f -ipath '*license*' -not -name '*.go') - [ -n "${licenses}" ] || continue - - while IFS= read -r license; do - # Replace the local module directory with the module path. - # - https://golang.org/ref/mod#module-path - relative="${module}${license:${#directory}}" - - # Copy the license file with the same layout as the module. - destination="${OUT_DIR}/${relative%/*}" - install -d "${destination}" - install -m 0644 "${license}" "${destination}" - done <<< "${licenses}" -done <<< "${dependencies}" diff --git a/build/postgres-operator/Dockerfile b/build/postgres-operator/Dockerfile deleted file mode 100644 index 69c5953761..0000000000 --- a/build/postgres-operator/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM registry.access.redhat.com/ubi8/ubi-minimal - -COPY licenses /licenses - -COPY bin/postgres-operator /usr/local/bin - -RUN mkdir -p /opt/crunchy/conf - -COPY hack/tools/queries /opt/crunchy/conf - -RUN chgrp -R 0 /opt/crunchy/conf && chmod -R g=u opt/crunchy/conf - -USER 2 - -CMD ["postgres-operator"] diff --git a/cmd/postgres-operator/main.go b/cmd/postgres-operator/main.go index b2f8ae49b6..8545e9e241 100644 --- a/cmd/postgres-operator/main.go +++ b/cmd/postgres-operator/main.go @@ -1,4 +1,4 @@ -// Copyright 2017 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,19 +6,22 @@ package main import ( "context" + "crypto/tls" + "errors" "fmt" "net/http" "os" + "os/signal" "strconv" "strings" + "syscall" "time" "unicode" - "go.opentelemetry.io/otel" "k8s.io/apimachinery/pkg/util/validation" - "k8s.io/client-go/discovery" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" "github.com/crunchydata/postgres-operator/internal/bridge" "github.com/crunchydata/postgres-operator/internal/bridge/crunchybridgecluster" @@ -28,15 +31,15 @@ import ( "github.com/crunchydata/postgres-operator/internal/controller/standalone_pgadmin" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/kubernetes" "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/registration" + "github.com/crunchydata/postgres-operator/internal/tracing" "github.com/crunchydata/postgres-operator/internal/upgradecheck" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) -var versionString string - // assertNoError panics when err is not nil. func assertNoError(err error) { if err != nil { @@ -57,13 +60,37 @@ func initLogging() { } //+kubebuilder:rbac:groups="coordination.k8s.io",resources="leases",verbs={get,create,update,watch} +//+kubebuilder:rbac:groups="authentication.k8s.io",resources="tokenreviews",verbs={create} +//+kubebuilder:rbac:groups="authorization.k8s.io",resources="subjectaccessreviews",verbs={create} -func initManager() (runtime.Options, error) { - log := logging.FromContext(context.Background()) +func initManager(ctx context.Context) (runtime.Options, error) { + log := logging.FromContext(ctx) options := runtime.Options{} options.Cache.SyncPeriod = initialize.Pointer(time.Hour) + // If we aren't using it, http/2 should be disabled + // due to its vulnerabilities. More specifically, disabling http/2 will + // prevent from being vulnerable to the HTTP/2 Stream Cancellation and + // Rapid Reset CVEs. For more information see: + // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 + // - https://github.com/advisories/GHSA-4374-p667-p6c8 + options.Metrics.TLSOpts = append(options.Metrics.TLSOpts, func(c *tls.Config) { + log.Info("enabling metrics via http/1.1") + c.NextProtos = []string{"http/1.1"} + }) + + // Use https port + options.Metrics.BindAddress = ":8443" + options.Metrics.SecureServing = true + + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.3/pkg/metrics/filters#WithAuthenticationAndAuthorization + options.Metrics.FilterProvider = filters.WithAuthenticationAndAuthorization + + // Set health probe port options.HealthProbeBindAddress = ":8081" // Enable leader elections when configured with a valid Lease.coordination.k8s.io name. @@ -120,33 +147,67 @@ func initManager() (runtime.Options, error) { } func main() { - // This context is canceled by SIGINT, SIGTERM, or by calling shutdown. - ctx, shutdown := context.WithCancel(runtime.SignalHandler()) - - otelFlush, err := initOpenTelemetry() - assertNoError(err) - defer otelFlush() + running, stopRunning := context.WithCancel(context.Background()) + defer stopRunning() + initVersion() initLogging() - - log := logging.FromContext(ctx) + log := logging.FromContext(running) log.V(1).Info("debug flag set to true") + // Start a goroutine that waits for SIGINT or SIGTERM. + { + signals := []os.Signal{os.Interrupt, syscall.SIGTERM} + receive := make(chan os.Signal, len(signals)) + signal.Notify(receive, signals...) + go func() { + // Wait for a signal then immediately restore the default signal handlers. + // After this, a SIGHUP, SIGINT, or SIGTERM causes the program to exit. + // - https://pkg.go.dev/os/signal#hdr-Default_behavior_of_signals_in_Go_programs + s := <-receive + signal.Stop(receive) + + log.Info("received signal from OS", "signal", s.String()) + stopRunning() + }() + } + features := feature.NewGate() assertNoError(features.Set(os.Getenv("PGO_FEATURE_GATES"))) - log.Info("feature gates enabled", "PGO_FEATURE_GATES", features.String()) + + running = feature.NewContext(running, features) + log.Info("feature gates", + // These are set by the user + "PGO_FEATURE_GATES", feature.ShowAssigned(running), + // These are enabled, including features that are on by default + "enabled", feature.ShowEnabled(running)) + + // Initialize OpenTelemetry and flush data when there is a panic. + otelFinish, err := initOpenTelemetry(running) + assertNoError(err) + defer func(ctx context.Context) { _ = otelFinish(ctx) }(running) + + tracing.SetDefaultTracer(tracing.New("github.com/CrunchyData/postgres-operator")) cfg, err := runtime.GetConfig() assertNoError(err) + cfg.UserAgent = userAgent cfg.Wrap(otelTransportWrapper()) + // TODO(controller-runtime): Set config.WarningHandler instead after v0.19.0. // Configure client-go to suppress warnings when warning headers are encountered. This prevents // warnings from being logged over and over again during reconciliation (e.g. this will suppress // deprecation warnings when using an older version of a resource for backwards compatibility). rest.SetDefaultWarningHandler(rest.NoWarnings{}) - options, err := initManager() + k8s, err := kubernetes.NewDiscoveryRunner(cfg) + assertNoError(err) + assertNoError(k8s.Read(running)) + + log.Info("connected to Kubernetes", "api", k8s.Version().String(), "openshift", k8s.IsOpenShift()) + + options, err := initManager(running) assertNoError(err) // Add to the Context that Manager passes to Reconciler.Start, Runnable.Start, @@ -154,24 +215,21 @@ func main() { options.BaseContext = func() context.Context { ctx := context.Background() ctx = feature.NewContext(ctx, features) + ctx = kubernetes.NewAPIContext(ctx, k8s) return ctx } mgr, err := runtime.NewManager(cfg, options) assertNoError(err) + assertNoError(mgr.Add(k8s)) - openshift := isOpenshift(cfg) - if openshift { - log.Info("detected OpenShift environment") - } - - registrar, err := registration.NewRunner(os.Getenv("RSA_KEY"), os.Getenv("TOKEN_PATH"), shutdown) + registrar, err := registration.NewRunner(os.Getenv("RSA_KEY"), os.Getenv("TOKEN_PATH"), stopRunning) assertNoError(err) assertNoError(mgr.Add(registrar)) token, _ := registrar.CheckToken() // add all PostgreSQL Operator controllers to the runtime manager - addControllersToManager(mgr, openshift, log, registrar) + addControllersToManager(mgr, log, registrar) if features.Enabled(feature.BridgeIdentifiers) { constructor := func() *bridge.Client { @@ -191,7 +249,6 @@ func main() { assertNoError( upgradecheck.ManagedScheduler( mgr, - openshift, os.Getenv("CHECK_FOR_UPGRADES_URL"), versionString, token, @@ -204,22 +261,39 @@ func main() { assertNoError(mgr.AddHealthzCheck("health", healthz.Ping)) assertNoError(mgr.AddReadyzCheck("check", healthz.Ping)) - log.Info("starting controller runtime manager and will wait for signal to exit") + // Start the manager and wait for its context to be canceled. + stopped := make(chan error, 1) + go func() { stopped <- mgr.Start(running) }() + <-running.Done() + + // Set a deadline for graceful termination. + log.Info("shutting down") + stopping, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + // Wait for the manager to return or the deadline to pass. + select { + case err = <-stopped: + case <-stopping.Done(): + err = stopping.Err() + } - assertNoError(mgr.Start(ctx)) - log.Info("signal received, exiting") + // Flush any telemetry with the remaining time we have. + if err = errors.Join(err, otelFinish(stopping)); err != nil { + log.Error(err, "shutdown failed") + } else { + log.Info("shutdown complete") + } } // addControllersToManager adds all PostgreSQL Operator controllers to the provided controller // runtime manager. -func addControllersToManager(mgr runtime.Manager, openshift bool, log logging.Logger, reg registration.Registration) { +func addControllersToManager(mgr runtime.Manager, log logging.Logger, reg registration.Registration) { pgReconciler := &postgrescluster.Reconciler{ Client: mgr.GetClient(), - IsOpenShift: openshift, Owner: postgrescluster.ControllerName, Recorder: mgr.GetEventRecorderFor(postgrescluster.ControllerName), Registration: reg, - Tracer: otel.Tracer(postgrescluster.ControllerName), } if err := pgReconciler.SetupWithManager(mgr); err != nil { @@ -240,10 +314,9 @@ func addControllersToManager(mgr runtime.Manager, openshift bool, log logging.Lo } pgAdminReconciler := &standalone_pgadmin.PGAdminReconciler{ - Client: mgr.GetClient(), - Owner: "pgadmin-controller", - Recorder: mgr.GetEventRecorderFor(naming.ControllerPGAdmin), - IsOpenShift: openshift, + Client: mgr.GetClient(), + Owner: "pgadmin-controller", + Recorder: mgr.GetEventRecorderFor(naming.ControllerPGAdmin), } if err := pgAdminReconciler.SetupWithManager(mgr); err != nil { @@ -270,33 +343,3 @@ func addControllersToManager(mgr runtime.Manager, openshift bool, log logging.Lo os.Exit(1) } } - -func isOpenshift(cfg *rest.Config) bool { - const sccGroupName, sccKind = "security.openshift.io", "SecurityContextConstraints" - - client, err := discovery.NewDiscoveryClientForConfig(cfg) - assertNoError(err) - - groups, err := client.ServerGroups() - if err != nil { - assertNoError(err) - } - for _, g := range groups.Groups { - if g.Name != sccGroupName { - continue - } - for _, v := range g.Versions { - resourceList, err := client.ServerResourcesForGroupVersion(v.GroupVersion) - if err != nil { - assertNoError(err) - } - for _, r := range resourceList.APIResources { - if r.Kind == sccKind { - return true - } - } - } - } - - return false -} diff --git a/cmd/postgres-operator/main_test.go b/cmd/postgres-operator/main_test.go index f369ce6bd3..49334660d6 100644 --- a/cmd/postgres-operator/main_test.go +++ b/cmd/postgres-operator/main_test.go @@ -1,10 +1,11 @@ -// Copyright 2017 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package main import ( + "context" "reflect" "testing" "time" @@ -14,14 +15,21 @@ import ( ) func TestInitManager(t *testing.T) { + ctx := context.Background() + t.Run("Defaults", func(t *testing.T) { - options, err := initManager() + options, err := initManager(ctx) assert.NilError(t, err) if assert.Check(t, options.Cache.SyncPeriod != nil) { assert.Equal(t, *options.Cache.SyncPeriod, time.Hour) } + assert.Equal(t, len(options.Metrics.TLSOpts), 1) + assert.Assert(t, options.Metrics.BindAddress == ":8443") + assert.Assert(t, options.Metrics.SecureServing == true) + assert.Assert(t, options.Metrics.FilterProvider != nil) + assert.Assert(t, options.HealthProbeBindAddress == ":8081") assert.DeepEqual(t, options.Controller.GroupKindConcurrency, @@ -36,6 +44,10 @@ func TestInitManager(t *testing.T) { options.Cache.SyncPeriod = nil options.Controller.GroupKindConcurrency = nil options.HealthProbeBindAddress = "" + options.Metrics.TLSOpts = nil + options.Metrics.BindAddress = "" + options.Metrics.SecureServing = false + options.Metrics.FilterProvider = nil assert.Assert(t, reflect.ValueOf(options).IsZero(), "expected remaining fields to be unset:\n%+v", options) @@ -48,7 +60,7 @@ func TestInitManager(t *testing.T) { t.Run("Invalid", func(t *testing.T) { t.Setenv("PGO_CONTROLLER_LEASE_NAME", "INVALID_NAME") - options, err := initManager() + options, err := initManager(ctx) assert.ErrorContains(t, err, "PGO_CONTROLLER_LEASE_NAME") assert.ErrorContains(t, err, "invalid") @@ -59,7 +71,7 @@ func TestInitManager(t *testing.T) { t.Run("Valid", func(t *testing.T) { t.Setenv("PGO_CONTROLLER_LEASE_NAME", "valid-name") - options, err := initManager() + options, err := initManager(ctx) assert.NilError(t, err) assert.Assert(t, options.LeaderElection == true) assert.Equal(t, options.LeaderElectionNamespace, "test-namespace") @@ -70,7 +82,7 @@ func TestInitManager(t *testing.T) { t.Run("PGO_TARGET_NAMESPACE", func(t *testing.T) { t.Setenv("PGO_TARGET_NAMESPACE", "some-such") - options, err := initManager() + options, err := initManager(ctx) assert.NilError(t, err) assert.Assert(t, cmp.Len(options.Cache.DefaultNamespaces, 1), "expected only one configured namespace") @@ -81,7 +93,7 @@ func TestInitManager(t *testing.T) { t.Run("PGO_TARGET_NAMESPACES", func(t *testing.T) { t.Setenv("PGO_TARGET_NAMESPACES", "some-such,another-one") - options, err := initManager() + options, err := initManager(ctx) assert.NilError(t, err) assert.Assert(t, cmp.Len(options.Cache.DefaultNamespaces, 2), "expect two configured namespaces") @@ -95,7 +107,7 @@ func TestInitManager(t *testing.T) { for _, v := range []string{"-3", "0", "3.14"} { t.Setenv("PGO_WORKERS", v) - options, err := initManager() + options, err := initManager(ctx) assert.NilError(t, err) assert.DeepEqual(t, options.Controller.GroupKindConcurrency, map[string]int{ @@ -107,7 +119,7 @@ func TestInitManager(t *testing.T) { t.Run("Valid", func(t *testing.T) { t.Setenv("PGO_WORKERS", "19") - options, err := initManager() + options, err := initManager(ctx) assert.NilError(t, err) assert.DeepEqual(t, options.Controller.GroupKindConcurrency, map[string]int{ diff --git a/cmd/postgres-operator/open_telemetry.go b/cmd/postgres-operator/open_telemetry.go index 2c9eedc135..3ecb630eed 100644 --- a/cmd/postgres-operator/open_telemetry.go +++ b/cmd/postgres-operator/open_telemetry.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,79 +6,91 @@ package main import ( "context" - "fmt" - "io" + "errors" "net/http" "os" + "go.opentelemetry.io/contrib/exporters/autoexport" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "go.opentelemetry.io/contrib/propagators/autoprop" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace" - "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" - "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/sdk/resource" "go.opentelemetry.io/otel/sdk/trace" -) + semconv "go.opentelemetry.io/otel/semconv/v1.27.0" -func initOpenTelemetry() (func(), error) { - // At the time of this writing, the SDK (go.opentelemetry.io/otel@v1.2.0) - // does not automatically initialize any exporter. We import the OTLP and - // stdout exporters and configure them below. Much of the OTLP exporter can - // be configured through environment variables. - // - // - https://github.com/open-telemetry/opentelemetry-go/issues/2310 - // - https://github.com/open-telemetry/opentelemetry-specification/blob/v1.8.0/specification/sdk-environment-variables.md + "github.com/crunchydata/postgres-operator/internal/logging" +) - switch os.Getenv("OTEL_TRACES_EXPORTER") { - case "json": - var closer io.Closer - filename := os.Getenv("OTEL_JSON_FILE") - options := []stdouttrace.Option{} +func initOpenTelemetry(ctx context.Context) (func(context.Context) error, error) { + var started []interface{ Shutdown(context.Context) error } - if filename != "" { - file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return nil, fmt.Errorf("unable to open exporter file: %w", err) - } - closer = file - options = append(options, stdouttrace.WithWriter(file)) + // shutdown returns the results of calling all the Shutdown methods in started. + var shutdown = func(ctx context.Context) error { + var err error + for _, s := range started { + err = errors.Join(err, s.Shutdown(ctx)) } + started = nil + return err + } - exporter, err := stdouttrace.New(options...) - if err != nil { - return nil, fmt.Errorf("unable to initialize stdout exporter: %w", err) - } + // The default for OTEL_PROPAGATORS is "tracecontext,baggage". + otel.SetTextMapPropagator(autoprop.NewTextMapPropagator()) - provider := trace.NewTracerProvider(trace.WithBatcher(exporter)) - flush := func() { - _ = provider.Shutdown(context.TODO()) - if closer != nil { - _ = closer.Close() - } - } + // Skip any remaining setup when OTEL_SDK_DISABLED is exactly "true". + // - https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables + if os.Getenv("OTEL_SDK_DISABLED") == "true" { + return shutdown, nil + } - otel.SetTracerProvider(provider) - return flush, nil + log := logging.FromContext(ctx).WithName("open-telemetry") + otel.SetLogger(log) + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { + // TODO(events): Emit this as an event instead. + log.V(1).Info(semconv.ExceptionEventName, + string(semconv.ExceptionMessageKey), err) + })) - case "otlp": - client := otlptracehttp.NewClient() - exporter, err := otlptrace.New(context.TODO(), client) - if err != nil { - return nil, fmt.Errorf("unable to initialize OTLP exporter: %w", err) - } + // Build a resource from the OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME environment variables. + // - https://opentelemetry.io/docs/languages/go/resources + self, _ := resource.Merge(resource.NewSchemaless( + semconv.ServiceVersion(versionString), + ), resource.Default()) - provider := trace.NewTracerProvider(trace.WithBatcher(exporter)) - flush := func() { - _ = provider.Shutdown(context.TODO()) + // Provide defaults for some other detectable attributes. + if r, err := resource.New(ctx, + resource.WithProcessRuntimeName(), + resource.WithProcessRuntimeVersion(), + resource.WithProcessRuntimeDescription(), + ); err == nil { + self, _ = resource.Merge(r, self) + } + if r, err := resource.New(ctx, + resource.WithHost(), + resource.WithOS(), + ); err == nil { + self, _ = resource.Merge(r, self) + } + + // The default for OTEL_TRACES_EXPORTER is "otlp" but we prefer "none". + // Only assign an exporter when the environment variable is set. + if os.Getenv("OTEL_TRACES_EXPORTER") != "" { + exporter, err := autoexport.NewSpanExporter(ctx) + if err != nil { + return nil, errors.Join(err, shutdown(ctx)) } + // The defaults for this batch processor come from the OTEL_BSP_* environment variables. + // - https://pkg.go.dev/go.opentelemetry.io/otel/sdk/internal/env + provider := trace.NewTracerProvider( + trace.WithBatcher(exporter), + trace.WithResource(self), + ) + started = append(started, provider) otel.SetTracerProvider(provider) - return flush, nil } - // $OTEL_TRACES_EXPORTER is unset or unknown, so no TracerProvider has been assigned. - // The default at this time is a single "no-op" tracer. - - return func() {}, nil + return shutdown, nil } // otelTransportWrapper creates a function that wraps the provided net/http.RoundTripper diff --git a/cmd/postgres-operator/version.go b/cmd/postgres-operator/version.go new file mode 100644 index 0000000000..78bbf87782 --- /dev/null +++ b/cmd/postgres-operator/version.go @@ -0,0 +1,26 @@ +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +var userAgent string +var versionString string + +func initVersion() { + command := "unknown" + if len(os.Args) > 0 && len(os.Args[0]) > 0 { + command = filepath.Base(os.Args[0]) + } + if len(versionString) > 0 { + command += "/" + versionString + } + userAgent = fmt.Sprintf("%s (%s/%s)", command, runtime.GOOS, runtime.GOARCH) +} diff --git a/config/README.md b/config/README.md index 73d2e59e6f..665ba13163 100644 --- a/config/README.md +++ b/config/README.md @@ -1,5 +1,5 @@ diff --git a/config/crd/bases/postgres-operator.crunchydata.com_crunchybridgeclusters.yaml b/config/crd/bases/postgres-operator.crunchydata.com_crunchybridgeclusters.yaml index 82db84b466..d700d660a1 100644 --- a/config/crd/bases/postgres-operator.crunchydata.com_crunchybridgeclusters.yaml +++ b/config/crd/bases/postgres-operator.crunchydata.com_crunchybridgeclusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.4 + controller-gen.kubebuilder.io/version: v0.17.3 name: crunchybridgeclusters.postgres-operator.crunchydata.com spec: group: postgres-operator.crunchydata.com @@ -90,6 +90,7 @@ spec: - aws - azure - gcp + maxLength: 10 type: string x-kubernetes-validations: - message: immutable diff --git a/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml b/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml index da729cfaf2..accc248ec9 100644 --- a/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml +++ b/config/crd/bases/postgres-operator.crunchydata.com_pgadmins.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.4 + controller-gen.kubebuilder.io/version: v0.17.3 name: pgadmins.postgres-operator.crunchydata.com spec: group: postgres-operator.crunchydata.com @@ -325,7 +325,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -340,7 +339,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -506,7 +504,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -521,7 +518,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -684,7 +680,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -699,7 +694,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -865,7 +859,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -880,7 +873,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -973,24 +965,27 @@ spec: More info: https://www.pgadmin.org/docs/pgadmin4/latest/external_database.html properties: key: - description: The key of the secret to select from. Must be - a valid secret key. + description: Name of the data field within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + description: Name of the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string optional: - description: Specify whether the Secret or its key must be - defined + description: Whether or not the Secret or its data must be + defined. Defaults to false. type: boolean required: - key + - name type: object x-kubernetes-map-type: atomic files: @@ -998,8 +993,9 @@ spec: Files allows the user to mount projected volumes into the pgAdmin container so that files can be referenced by pgAdmin as needed. items: - description: Projection that may be projected along with other - supported volume types + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. properties: clusterTrustBundle: description: |- @@ -1316,7 +1312,7 @@ spec: type: array gunicorn: description: |- - Settings for the gunicorn server. + Settings for the Gunicorn server. More info: https://docs.gunicorn.org/en/latest/settings.html type: object x-kubernetes-preserve-unknown-fields: true @@ -1326,32 +1322,84 @@ spec: More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html properties: key: - description: The key of the secret to select from. Must be - a valid secret key. + description: Name of the data field within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + description: Name of the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string optional: - description: Specify whether the Secret or its key must be - defined + description: Whether or not the Secret or its data must be + defined. Defaults to false. type: boolean required: - key + - name type: object x-kubernetes-map-type: atomic + oauthConfigurations: + description: |- + Secrets for the `OAUTH2_CONFIG` setting. If there are `OAUTH2_CONFIG` values + in the settings field, they will be combined with the values loaded here. + More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html + items: + properties: + name: + description: The OAUTH2_NAME of this configuration. + maxLength: 20 + minLength: 1 + pattern: ^[A-Za-z0-9]+$ + type: string + secret: + description: A Secret containing the settings of one OAuth2 + provider as a JSON object. + properties: + key: + description: Name of the data field within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") + name: + description: Name of the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + required: + - name + - secret + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map settings: description: |- Settings for the pgAdmin server process. Keys should be uppercase and values must be constants. More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html type: object + x-kubernetes-map-type: granular x-kubernetes-preserve-unknown-fields: true type: object dataVolumeClaimSpec: @@ -1541,7 +1589,7 @@ spec: set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Alpha) Using this field requires the VolumeAttributesClass feature gate to be enabled. + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). type: string volumeMode: description: |- @@ -1553,6 +1601,12 @@ spec: backing this claim. type: string type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) image: description: The image name to use for pgAdmin instance. type: string @@ -1565,6 +1619,7 @@ spec: - Always - Never - IfNotPresent + maxLength: 15 type: string imagePullSecrets: description: |- @@ -1588,6 +1643,731 @@ spec: type: object x-kubernetes-map-type: atomic type: array + instrumentation: + description: |- + Configuration for the OpenTelemetry collector container used to collect + logs and metrics. + properties: + config: + description: Config is the place for users to configure exporters + and provide files. + properties: + detectors: + description: |- + Resource detectors add identifying attributes to logs and metrics. These run in the order they are defined. + More info: https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/resourcedetectionprocessor#readme + items: + properties: + attributes: + additionalProperties: + type: boolean + description: |- + Attributes to use from this detector. Detectors usually add every attribute + they know automatically. Names omitted here behave according to detector defaults. + maxProperties: 30 + minProperties: 1 + type: object + x-kubernetes-map-type: atomic + name: + description: 'Name of the resource detector to enable: + `aks`, `eks`, `gcp`, etc.' + maxLength: 20 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + environmentVariables: + description: |- + EnvironmentVariables allows the user to add environment variables to the + collector container. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + x-kubernetes-validations: + - message: Cannot overwrite environment variables set by + operator + rule: self.name != 'K8S_POD_NAMESPACE' && self.name != + 'K8S_POD_NAME' && self.name != 'PGPASSWORD' + minItems: 1 + type: array + x-kubernetes-list-type: atomic + exporters: + description: |- + Exporters allows users to configure OpenTelemetry exporters that exist + in the collector image. + type: object + x-kubernetes-preserve-unknown-fields: true + files: + description: |- + Files allows the user to mount projected volumes into the collector + Pod so that files can be referenced by the collector as needed. + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root + to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path. + Must be utf-8 encoded. The first item of + the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the + Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about + the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + minItems: 1 + type: array + x-kubernetes-list-type: atomic + type: object + image: + description: |- + Image name to use for collector containers. When omitted, the value + comes from an operator environment variable. + type: string + logs: + description: Logs is the place for users to configure the log + collection. + properties: + batches: + description: Log records are exported in small batches. Set + this field to change their size and frequency. + properties: + maxDelay: + default: 200ms + description: |- + Maximum time to wait before exporting a log record. Higher numbers + allow more records to be deduplicated and compressed before export. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^((PT)?( *[0-9]+ *(?i:(ms|s|m)|(milli|sec|min)s?))+|0)$ + type: string + x-kubernetes-validations: + - rule: duration("0") <= self && self <= duration("5m") + maxRecords: + description: |- + Maximum number of records to include in an exported batch. When present, + batches this size are sent without any further delay. + format: int32 + minimum: 1 + type: integer + minRecords: + default: 8192 + description: |- + Number of records to wait for before exporting a batch. Higher numbers + allow more records to be deduplicated and compressed before export. + format: int32 + minimum: 0 + type: integer + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: to disable batching, both minRecords and maxDelay + must be zero + rule: (has(self.minRecords) && self.minRecords == 0) == + (has(self.maxDelay) && self.maxDelay == duration('0')) + - message: minRecords cannot be larger than maxRecords + rule: '!has(self.maxRecords) || self.minRecords <= self.maxRecords' + exporters: + description: The names of exporters that should send logs. + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + retentionPeriod: + description: |- + How long to retain log files locally. An RFC 3339 duration or a number + and unit: `12 hr`, `3d`, `4 weeks`, etc. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^(PT)?( *[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$ + type: string + x-kubernetes-validations: + - message: must be at least one hour + rule: duration("1h") <= self && self <= duration("8760h") + type: object + metrics: + description: Metrics is the place for users to configure metrics + collection. + properties: + customQueries: + description: |- + Where users can turn off built-in metrics and also provide their own + custom queries. + properties: + add: + description: User defined queries and metrics. + items: + properties: + collectionInterval: + default: 5s + description: How often the queries should be run. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^((PT)?( *[0-9]+ *(?i:(ms|s|m)|(milli|sec|min)s?))+|0)$ + type: string + x-kubernetes-validations: + - rule: duration("0") <= self && self <= duration("60m") + databases: + description: |- + The databases to target with added custom queries. + Default behavior is to target `postgres`. + items: + type: string + type: array + name: + description: |- + The name of this batch of queries, which will be used in naming the OTel + SqlQuery receiver. + maxLength: 20 + pattern: ^[^\pZ\pC\pS]+$ + type: string + queries: + description: A ConfigMap holding the yaml file that + contains the queries. + properties: + key: + description: Name of the data field within the + ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") + name: + description: Name of the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + required: + - name + - queries + type: object + type: array + remove: + description: |- + A list of built-in queries that should be removed. If all queries for a + given SQL statement are removed, the SQL statement will no longer be run. + items: + type: string + type: array + type: object + exporters: + description: The names of exporters that should send metrics. + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + perDBMetricTargets: + description: User defined databases to target for default + per-db metrics + items: + type: string + type: array + type: object + resources: + description: Resources holds the resource requirements for the + collector container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object metadata: description: Metadata contains metadata for custom resources properties: @@ -1627,6 +2407,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1820,6 +2606,7 @@ spec: enum: - Administrator - User + maxLength: 15 type: string username: description: |- diff --git a/config/crd/bases/postgres-operator.crunchydata.com_pgupgrades.yaml b/config/crd/bases/postgres-operator.crunchydata.com_pgupgrades.yaml index 4ae831cfc7..7e98654ff6 100644 --- a/config/crd/bases/postgres-operator.crunchydata.com_pgupgrades.yaml +++ b/config/crd/bases/postgres-operator.crunchydata.com_pgupgrades.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.4 + controller-gen.kubebuilder.io/version: v0.17.3 name: pgupgrades.postgres-operator.crunchydata.com spec: group: postgres-operator.crunchydata.com @@ -325,7 +325,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -340,7 +339,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -506,7 +504,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -521,7 +518,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -684,7 +680,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -699,7 +694,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -865,7 +859,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -880,7 +873,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -963,6 +955,7 @@ spec: type: object fromPostgresVersion: description: The major version of PostgreSQL before the upgrade. + format: int32 maximum: 17 minimum: 11 type: integer @@ -978,12 +971,13 @@ spec: - Always - Never - IfNotPresent + maxLength: 15 type: string imagePullSecrets: description: |- The image pull secrets used to pull from a private registry. Changing this value causes all running PGUpgrade pods to restart. - https://k8s.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry items: description: |- LocalObjectReference contains enough information to let you locate the @@ -1001,6 +995,13 @@ spec: type: object x-kubernetes-map-type: atomic type: array + jobs: + description: |- + The number of simultaneous processes pg_upgrade should use. + More info: https://www.postgresql.org/docs/current/pgupgrade.html + format: int32 + minimum: 0 + type: integer metadata: description: Metadata contains metadata for custom resources properties: @@ -1014,14 +1015,14 @@ spec: type: object type: object postgresClusterName: - description: The name of the cluster to be updated + description: The name of the Postgres cluster to upgrade. minLength: 1 type: string priorityClassName: description: |- Priority class name for the PGUpgrade pod. Changing this value causes PGUpgrade pod to restart. - More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption type: string resources: description: Resource requirements for the PGUpgrade container. @@ -1044,6 +1045,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1076,13 +1083,9 @@ spec: More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object type: object - toPostgresImage: - description: |- - The image name to use for PostgreSQL containers after upgrade. - When omitted, the value comes from an operator environment variable. - type: string toPostgresVersion: description: The major version of PostgreSQL to be upgraded to. + format: int32 maximum: 17 minimum: 11 type: integer @@ -1127,11 +1130,30 @@ spec: type: string type: object type: array + transferMethod: + description: |- + The method pg_upgrade should use to transfer files to the new cluster. + More info: https://www.postgresql.org/docs/current/pgupgrade.html + enum: + - Clone + - Copy + - CopyFileRange + - Link + maxLength: 15 + type: string required: - fromPostgresVersion - postgresClusterName - toPostgresVersion type: object + x-kubernetes-validations: + - rule: self.fromPostgresVersion < self.toPostgresVersion + - message: Only Copy or Link before PostgreSQL 12 + rule: '!has(self.transferMethod) || (self.toPostgresVersion < 12 ? self.transferMethod + in ["Copy","Link"] : true)' + - message: Only Clone, Copy, or Link before PostgreSQL 17 + rule: '!has(self.transferMethod) || (self.toPostgresVersion < 17 ? self.transferMethod + in ["Clone","Copy","Link"] : true)' status: description: PGUpgradeStatus defines the observed state of PGUpgrade properties: diff --git a/config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml b/config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml index 6f9dd40f02..7457d2eeb7 100644 --- a/config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml +++ b/config/crd/bases/postgres-operator.crunchydata.com_postgresclusters.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.4 + controller-gen.kubebuilder.io/version: v0.17.3 name: postgresclusters.postgres-operator.crunchydata.com spec: group: postgres-operator.crunchydata.com @@ -14,7 +14,7 @@ spec: singular: postgrescluster scope: Namespaced versions: - - name: v1beta1 + - name: v1 schema: openAPIV3Schema: description: PostgresCluster is the Schema for the postgresclusters API @@ -39,6 +39,113 @@ spec: spec: description: PostgresClusterSpec defines the desired state of PostgresCluster properties: + authentication: + description: Authentication settings for the PostgreSQL server + properties: + rules: + description: |- + Postgres compares every new connection to these rules in the order they are + defined. The first rule that matches determines if and how the connection + must then authenticate. Connections that match no rules are disconnected. + + When this is omitted or empty, Postgres accepts encrypted connections to any + database from users that have a password. To refuse all network connections, + set this to one rule that matches "host" connections to the "reject" method. + + More info: https://www.postgresql.org/docs/current/auth-pg-hba-conf.html + items: + properties: + connection: + description: |- + The connection transport this rule matches. Typical values are: + 1. "host" for network connections that may or may not be encrypted. + 2. "hostssl" for network connections encrypted using TLS. + 3. "hostgssenc" for network connections encrypted using GSSAPI. + maxLength: 20 + minLength: 1 + pattern: ^[-a-z0-9]+$ + type: string + databases: + description: Which databases this rule matches. When omitted + or empty, this rule matches all databases. + items: + maxLength: 63 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + hba: + description: One line of the "pg_hba.conf" file. Changes + to this value will be automatically reloaded without validation. + maxLength: 100 + minLength: 1 + pattern: ^[[:print:]]+$ + type: string + x-kubernetes-validations: + - message: cannot include other files + rule: '!self.trim().startsWith("include")' + method: + description: |- + The authentication method to use when a connection matches this rule. + The special value "reject" refuses connections that match this rule. + + More info: https://www.postgresql.org/docs/current/auth-methods.html + maxLength: 20 + minLength: 1 + pattern: ^[-a-z0-9]+$ + type: string + x-kubernetes-validations: + - message: the "trust" method is unsafe + rule: self != "trust" + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Additional settings for this rule or its authentication + method. + maxProperties: 20 + type: object + x-kubernetes-map-type: atomic + users: + description: Which user names this rule matches. When omitted + or empty, this rule matches all users. + items: + maxLength: 63 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: '"hba" cannot be combined with other fields' + rule: '[has(self.hba), has(self.connection) || has(self.databases) + || has(self.method) || has(self.options) || has(self.users)].exists_one(b,b)' + - message: '"connection" and "method" are required' + rule: has(self.hba) || (has(self.connection) && has(self.method)) + - message: the "ldap" method requires an "ldapbasedn", "ldapprefix", + or "ldapsuffix" option + rule: has(self.hba) || self.method != "ldap" || (has(self.options) + && ["ldapbasedn","ldapprefix","ldapsuffix"].exists(k, k + in self.options)) + - message: cannot use "ldapbasedn", "ldapbinddn", "ldapbindpasswd", + "ldapsearchattribute", or "ldapsearchfilter" options with + "ldapprefix" or "ldapsuffix" options + rule: has(self.hba) || self.method != "ldap" || !has(self.options) + || 2 > size([["ldapprefix","ldapsuffix"], ["ldapbasedn","ldapbinddn","ldapbindpasswd","ldapsearchattribute","ldapsearchfilter"]].filter(a, + a.exists(k, k in self.options))) + - message: the "radius" method requires "radiusservers" and + "radiussecrets" options + rule: has(self.hba) || self.method != "radius" || (has(self.options) + && ["radiusservers","radiussecrets"].all(k, k in self.options)) + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + type: object backups: description: PostgreSQL backup configuration properties: @@ -52,8 +159,9 @@ spec: PostgreSQL Operator: https://pgbackrest.org/configuration.html items: - description: Projection that may be projected along with - other supported volume types + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. properties: clusterTrustBundle: description: |- @@ -682,7 +790,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -697,7 +804,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -866,7 +972,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -881,7 +986,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -1048,7 +1152,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -1063,7 +1166,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -1232,7 +1334,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -1247,7 +1348,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -1359,6 +1459,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -1439,6 +1545,57 @@ spec: format: int32 minimum: 60 type: integer + volumes: + description: Volumes to add to Backup Job Pods + properties: + additional: + description: Additional pre-existing volumes to add + to the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object type: object manual: description: Defines details for manual pgBackRest backup @@ -1768,7 +1925,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -1783,7 +1939,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -1952,7 +2107,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -1967,7 +2121,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -2134,7 +2287,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -2149,7 +2301,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -2318,7 +2469,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -2333,7 +2483,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -2445,6 +2594,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -2763,7 +2918,6 @@ spec: - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. If this value is nil, the behavior is equivalent to the Honor policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. type: string nodeTaintsPolicy: description: |- @@ -2774,7 +2928,6 @@ spec: - Ignore: node taints are ignored. All nodes are included. If this value is nil, the behavior is equivalent to the Ignore policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. type: string topologyKey: description: |- @@ -2816,6 +2969,57 @@ spec: - whenUnsatisfiable type: object type: array + volumes: + description: Volumes to add to the Repo Host Pod + properties: + additional: + description: Additional pre-existing volumes to add + to the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object type: object repos: description: Defines a pgBackRest repository @@ -3092,7 +3296,7 @@ spec: set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Alpha) Using this field requires the VolumeAttributesClass feature gate to be enabled. + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). type: string volumeMode: description: |- @@ -3104,13 +3308,12 @@ spec: to the PersistentVolume backing this claim. type: string type: object + x-kubernetes-map-type: atomic x-kubernetes-validations: - message: missing accessModes - rule: has(self.accessModes) && size(self.accessModes) - > 0 + rule: 0 < size(self.accessModes) - message: missing storage request - rule: has(self.resources) && has(self.resources.requests) - && has(self.resources.requests.storage) + rule: has(self.resources.requests.storage) required: - volumeClaimSpec type: object @@ -3416,7 +3619,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -3431,7 +3633,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -3600,7 +3801,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -3615,7 +3815,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -3782,7 +3981,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -3797,7 +3995,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -3966,7 +4163,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -3981,7 +4177,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -4122,6 +4317,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -4195,6 +4396,57 @@ spec: type: string type: object type: array + volumes: + description: Volumes to add to Restore Job Pods + properties: + additional: + description: Additional pre-existing volumes to add + to the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object required: - enabled - repoName @@ -4228,6 +4480,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -4287,6 +4545,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -4337,11 +4601,14 @@ spec: type: object type: object config: + description: General configuration of the PostgreSQL server properties: files: + description: Files to mount under "/etc/postgres". items: - description: Projection that may be projected along with other - supported volume types + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. properties: clusterTrustBundle: description: |- @@ -4656,6 +4923,54 @@ spec: type: object type: object type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_"))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' type: object customReplicationTLSSecret: description: |- @@ -5085,7 +5400,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -5100,7 +5414,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -5268,7 +5581,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -5283,7 +5595,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -5449,7 +5760,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -5464,7 +5774,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -5632,7 +5941,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -5647,7 +5955,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -5736,8 +6043,9 @@ spec: PostgreSQL Operator: https://pgbackrest.org/configuration.html items: - description: Projection that may be projected along with - other supported volume types + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. properties: clusterTrustBundle: description: |- @@ -6350,7 +6658,7 @@ spec: set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Alpha) Using this field requires the VolumeAttributesClass feature gate to be enabled. + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). type: string volumeMode: description: |- @@ -6362,13 +6670,12 @@ spec: to the PersistentVolume backing this claim. type: string type: object + x-kubernetes-map-type: atomic x-kubernetes-validations: - message: missing accessModes - rule: has(self.accessModes) && size(self.accessModes) - > 0 + rule: 0 < size(self.accessModes) - message: missing storage request - rule: has(self.resources) && has(self.resources.requests) - && has(self.resources.requests.storage) + rule: has(self.resources.requests.storage) required: - volumeClaimSpec type: object @@ -6397,6 +6704,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -6476,10 +6789,66 @@ spec: type: string type: object type: array + volumes: + description: Volumes to add to Restore Job Pods + properties: + additional: + description: Additional pre-existing volumes to add to + the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object required: - repo - stanza type: object + x-kubernetes-validations: + - fieldPath: .repo + message: Only S3, GCS or Azure repos can be used as a pgBackRest + data source. + rule: '!has(self.repo.volume)' postgresCluster: description: |- Defines a pgBackRest data source that can be used to pre-populate the PostgreSQL data @@ -6777,7 +7146,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -6792,7 +7160,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -6960,7 +7327,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -6975,7 +7341,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -7141,7 +7506,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -7156,7 +7520,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -7324,7 +7687,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -7339,7 +7701,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -7473,6 +7834,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -7546,6 +7913,57 @@ spec: type: string type: object type: array + volumes: + description: Volumes to add to Restore Job Pods + properties: + additional: + description: Additional pre-existing volumes to add to + the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object required: - repoName type: object @@ -7645,6 +8063,7 @@ spec: - Always - Never - IfNotPresent + maxLength: 15 type: string imagePullSecrets: description: |- @@ -7964,7 +8383,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -7979,7 +8397,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -8146,7 +8563,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -8161,7 +8577,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -8326,7 +8741,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -8341,7 +8755,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -8508,7 +8921,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -8523,7 +8935,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -8777,7 +9188,7 @@ spec: Cannot be updated. items: description: EnvFromSource represents the source of - a set of ConfigMaps + a set of ConfigMaps or Secrets properties: configMapRef: description: The ConfigMap to select from @@ -8798,8 +9209,8 @@ spec: type: object x-kubernetes-map-type: atomic prefix: - description: An optional identifier to prepend to - each key in the ConfigMap. Must be a C_IDENTIFIER. + description: Optional text to prepend to the name + of each environment variable. Must be a C_IDENTIFIER. type: string secretRef: description: The Secret to select from @@ -8850,7 +9261,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -8865,7 +9277,7 @@ spec: x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -8915,8 +9327,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that + the container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -8929,8 +9341,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -8962,7 +9374,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -8977,7 +9390,7 @@ spec: x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -9027,8 +9440,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that + the container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -9041,8 +9454,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -9061,6 +9474,12 @@ spec: - port type: object type: object + stopSignal: + description: |- + StopSignal defines which signal will be sent to a container when it is being stopped. + If not specified, the default is defined by the container runtime in use. + StopSignal can only be set for Pods with a non-empty .spec.os.name + type: string type: object livenessProbe: description: |- @@ -9070,7 +9489,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -9091,8 +9511,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a - GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -9111,8 +9530,8 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request to - perform. + description: HTTPGet specifies an HTTP GET request + to perform. properties: host: description: |- @@ -9179,8 +9598,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a + TCP port. properties: host: description: 'Optional: Host name to connect to, @@ -9285,7 +9704,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -9306,8 +9726,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a - GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -9326,8 +9745,8 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request to - perform. + description: HTTPGet specifies an HTTP GET request + to perform. properties: host: description: |- @@ -9394,8 +9813,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a + TCP port. properties: host: description: 'Optional: Host name to connect to, @@ -9482,6 +9901,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -9605,7 +10030,7 @@ spec: procMount: description: |- procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for + The default value is Default which uses the container runtime defaults for readonly paths and masked paths. This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. @@ -9738,7 +10163,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute in + the container. properties: command: description: |- @@ -9759,8 +10185,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving a - GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -9779,8 +10204,8 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request to - perform. + description: HTTPGet specifies an HTTP GET request + to perform. properties: host: description: |- @@ -9847,8 +10272,8 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving - a TCP port. + description: TCPSocket specifies a connection to a + TCP port. properties: host: description: 'Optional: Host name to connect to, @@ -10219,7 +10644,7 @@ spec: set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Alpha) Using this field requires the VolumeAttributesClass feature gate to be enabled. + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). type: string volumeMode: description: |- @@ -10231,12 +10656,12 @@ spec: PersistentVolume backing this claim. type: string type: object + x-kubernetes-map-type: atomic x-kubernetes-validations: - message: missing accessModes - rule: has(self.accessModes) && size(self.accessModes) > 0 + rule: 0 < size(self.accessModes) - message: missing storage request - rule: has(self.resources) && has(self.resources.requests) - && has(self.resources.requests.storage) + rule: has(self.resources.requests.storage) metadata: description: Metadata contains metadata for custom resources properties: @@ -10299,6 +10724,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -10360,6 +10791,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -10592,7 +11029,7 @@ spec: set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Alpha) Using this field requires the VolumeAttributesClass feature gate to be enabled. + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). type: string volumeMode: description: |- @@ -10604,13 +11041,12 @@ spec: the PersistentVolume backing this claim. type: string type: object + x-kubernetes-map-type: atomic x-kubernetes-validations: - message: missing accessModes - rule: has(self.accessModes) && size(self.accessModes) - > 0 + rule: 0 < size(self.accessModes) - message: missing storage request - rule: has(self.resources) && has(self.resources.requests) - && has(self.resources.requests.storage) + rule: has(self.resources.requests.storage) name: description: |- The name for the tablespace, used as the path name for the volume. @@ -10793,7 +11229,6 @@ spec: - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. If this value is nil, the behavior is equivalent to the Honor policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. type: string nodeTaintsPolicy: description: |- @@ -10804,7 +11239,6 @@ spec: - Ignore: node taints are ignored. All nodes are included. If this value is nil, the behavior is equivalent to the Ignore policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. type: string topologyKey: description: |- @@ -10846,76 +11280,335 @@ spec: - whenUnsatisfiable type: object type: array - walVolumeClaimSpec: - description: |- - Defines a separate PersistentVolumeClaim for PostgreSQL's write-ahead log. - More info: https://www.postgresql.org/docs/current/wal.html + volumes: properties: - accessModes: - description: |- - accessModes contains the desired access modes the volume should have. - More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + additional: + description: Additional pre-existing volumes to add to the + pod. items: - type: string + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 type: array - x-kubernetes-list-type: atomic - dataSource: - description: |- - dataSource field can be used to specify either: - * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) - * An existing PVC (PersistentVolumeClaim) - If the provisioner or an external controller can support the specified data source, - it will create a new volume based on the contents of the specified data source. - When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, - and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. - If the namespace is specified, then dataSourceRef will not be copied to dataSource. - properties: - apiGroup: - description: |- - APIGroup is the group for the resource being referenced. - If APIGroup is not specified, the specified Kind must be in the core API group. - For any other third-party types, APIGroup is required. - type: string - kind: - description: Kind is the type of resource being referenced - type: string - name: - description: Name is the name of resource being referenced - type: string - required: - - kind + x-kubernetes-list-map-keys: - name - type: object - x-kubernetes-map-type: atomic - dataSourceRef: + x-kubernetes-list-type: map + temp: description: |- - dataSourceRef specifies the object from which to populate the volume with data, if a non-empty - volume is desired. This may be any object from a non-empty API group (non - core object) or a PersistentVolumeClaim object. - When this field is specified, volume binding will only succeed if the type of - the specified object matches some installed volume populator or dynamic - provisioner. - This field will replace the functionality of the dataSource field and as such - if both fields are non-empty, they must have the same value. For backwards - compatibility, when namespace isn't specified in dataSourceRef, - both fields (dataSource and dataSourceRef) will be set to the same - value automatically if one of them is empty and the other is non-empty. - When namespace is specified in dataSourceRef, - dataSource isn't set to the same value and must be empty. - There are three important differences between dataSource and dataSourceRef: - * While dataSource only allows two specific types of objects, dataSourceRef - allows any non-core object, as well as PersistentVolumeClaim objects. - * While dataSource ignores disallowed values (dropping them), dataSourceRef - preserves all values, and generates an error if a disallowed value is - specified. - * While dataSource only allows local objects, dataSourceRef allows objects - in any namespaces. - (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. - (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + An ephemeral volume for temporary files. + More info: https://kubernetes.io/docs/concepts/storage/ephemeral-volumes properties: - apiGroup: + accessModes: description: |- - APIGroup is the group for the resource being referenced. + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being + referenced + type: string + name: + description: Name is the name of resource being + referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being + referenced + type: string + name: + description: Name is the name of resource being + referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over volumes + to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to + the PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + type: object + walVolumeClaimSpec: + description: |- + Defines a separate PersistentVolumeClaim for PostgreSQL's write-ahead log. + More info: https://www.postgresql.org/docs/current/wal.html + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required. type: string @@ -10923,142 +11616,19792 @@ spec: description: Kind is the type of resource being referenced type: string name: - description: Name is the name of resource being referenced + description: Name is the name of resource being referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over volumes to consider + for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to the + PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + required: + - dataVolumeClaimSpec + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + instrumentation: + description: |- + Configuration for the OpenTelemetry collector container used to collect + logs and metrics. + properties: + config: + description: Config is the place for users to configure exporters + and provide files. + properties: + detectors: + description: |- + Resource detectors add identifying attributes to logs and metrics. These run in the order they are defined. + More info: https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/resourcedetectionprocessor#readme + items: + properties: + attributes: + additionalProperties: + type: boolean + description: |- + Attributes to use from this detector. Detectors usually add every attribute + they know automatically. Names omitted here behave according to detector defaults. + maxProperties: 30 + minProperties: 1 + type: object + x-kubernetes-map-type: atomic + name: + description: 'Name of the resource detector to enable: + `aks`, `eks`, `gcp`, etc.' + maxLength: 20 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + environmentVariables: + description: |- + EnvironmentVariables allows the user to add environment variables to the + collector container. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + x-kubernetes-validations: + - message: Cannot overwrite environment variables set by + operator + rule: self.name != 'K8S_POD_NAMESPACE' && self.name != + 'K8S_POD_NAME' && self.name != 'PGPASSWORD' + minItems: 1 + type: array + x-kubernetes-list-type: atomic + exporters: + description: |- + Exporters allows users to configure OpenTelemetry exporters that exist + in the collector image. + type: object + x-kubernetes-preserve-unknown-fields: true + files: + description: |- + Files allows the user to mount projected volumes into the collector + Pod so that files can be referenced by the collector as needed. + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root + to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path. + Must be utf-8 encoded. The first item of + the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the + Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about + the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + minItems: 1 + type: array + x-kubernetes-list-type: atomic + type: object + image: + description: |- + Image name to use for collector containers. When omitted, the value + comes from an operator environment variable. + type: string + logs: + description: Logs is the place for users to configure the log + collection. + properties: + batches: + description: Log records are exported in small batches. Set + this field to change their size and frequency. + properties: + maxDelay: + default: 200ms + description: |- + Maximum time to wait before exporting a log record. Higher numbers + allow more records to be deduplicated and compressed before export. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^((PT)?( *[0-9]+ *(?i:(ms|s|m)|(milli|sec|min)s?))+|0)$ + type: string + x-kubernetes-validations: + - rule: duration("0") <= self && self <= duration("5m") + maxRecords: + description: |- + Maximum number of records to include in an exported batch. When present, + batches this size are sent without any further delay. + format: int32 + minimum: 1 + type: integer + minRecords: + default: 8192 + description: |- + Number of records to wait for before exporting a batch. Higher numbers + allow more records to be deduplicated and compressed before export. + format: int32 + minimum: 0 + type: integer + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: to disable batching, both minRecords and maxDelay + must be zero + rule: (has(self.minRecords) && self.minRecords == 0) == + (has(self.maxDelay) && self.maxDelay == duration('0')) + - message: minRecords cannot be larger than maxRecords + rule: '!has(self.maxRecords) || self.minRecords <= self.maxRecords' + exporters: + description: The names of exporters that should send logs. + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + retentionPeriod: + description: |- + How long to retain log files locally. An RFC 3339 duration or a number + and unit: `12 hr`, `3d`, `4 weeks`, etc. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^(PT)?( *[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$ + type: string + x-kubernetes-validations: + - message: must be at least one hour + rule: duration("1h") <= self && self <= duration("8760h") + type: object + metrics: + description: Metrics is the place for users to configure metrics + collection. + properties: + customQueries: + description: |- + Where users can turn off built-in metrics and also provide their own + custom queries. + properties: + add: + description: User defined queries and metrics. + items: + properties: + collectionInterval: + default: 5s + description: How often the queries should be run. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^((PT)?( *[0-9]+ *(?i:(ms|s|m)|(milli|sec|min)s?))+|0)$ + type: string + x-kubernetes-validations: + - rule: duration("0") <= self && self <= duration("60m") + databases: + description: |- + The databases to target with added custom queries. + Default behavior is to target `postgres`. + items: + type: string + type: array + name: + description: |- + The name of this batch of queries, which will be used in naming the OTel + SqlQuery receiver. + maxLength: 20 + pattern: ^[^\pZ\pC\pS]+$ + type: string + queries: + description: A ConfigMap holding the yaml file that + contains the queries. + properties: + key: + description: Name of the data field within the + ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") + name: + description: Name of the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + required: + - name + - queries + type: object + type: array + remove: + description: |- + A list of built-in queries that should be removed. If all queries for a + given SQL statement are removed, the SQL statement will no longer be run. + items: + type: string + type: array + type: object + exporters: + description: The names of exporters that should send metrics. + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + perDBMetricTargets: + description: User defined databases to target for default + per-db metrics + items: + type: string + type: array + type: object + resources: + description: Resources holds the resource requirements for the + collector container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + monitoring: + description: The specification of monitoring tools that connect to + PostgreSQL + properties: + pgmonitor: + description: PGMonitorSpec defines the desired state of the pgMonitor + tool suite + properties: + exporter: + properties: + configuration: + description: |- + Projected volumes containing custom PostgreSQL Exporter configuration. Currently supports + the customization of PostgreSQL Exporter queries. If a "queries.yml" file is detected in + any volume projected using this field, it will be loaded using the "extend.query-path" flag: + https://github.com/prometheus-community/postgres_exporter#flags + Changing the values of field causes PostgreSQL and the exporter to restart. + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root + to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects a field + of the pod: only annotations, labels, + name, namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. + Must not be absolute or contain the + ''..'' path. Must be utf-8 encoded. + The first item of the relative path + must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + customTLSSecret: + description: |- + Projected secret containing custom TLS certificates to encrypt output from the exporter + web server + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + image: + description: |- + The image name to use for crunchy-postgres-exporter containers. The image may + also be set using the RELATED_IMAGE_PGEXPORTER environment variable. + type: string + resources: + description: |- + Changing this value causes PostgreSQL and the exporter to restart. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + type: object + type: object + openshift: + description: |- + Whether or not the PostgreSQL cluster is being deployed to an OpenShift + environment. If the field is unset, the operator will automatically + detect the environment. + type: boolean + patroni: + properties: + dynamicConfiguration: + description: |- + Patroni dynamic configuration settings. Changes to this value will be + automatically reloaded without validation. Changes to certain PostgreSQL + parameters cause PostgreSQL to restart. + More info: https://patroni.readthedocs.io/en/latest/dynamic_configuration.html + type: object + x-kubernetes-preserve-unknown-fields: true + leaderLeaseDurationSeconds: + default: 30 + description: |- + TTL of the cluster leader lock. "Think of it as the + length of time before initiation of the automatic failover process." + Changing this value causes PostgreSQL to restart. + format: int32 + minimum: 3 + type: integer + logging: + description: Patroni log configuration settings. + properties: + level: + default: INFO + description: |- + The Patroni log level. + More info: https://docs.python.org/3/library/logging.html#levels + enum: + - CRITICAL + - ERROR + - WARNING + - INFO + - DEBUG + - NOTSET + maxLength: 10 + type: string + storageLimit: + anyOf: + - type: integer + - type: string + description: |- + Limits the total amount of space taken by Patroni log files. + Minimum value is 25MB. + More info: https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/quantity + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + required: + - storageLimit + type: object + port: + default: 8008 + description: |- + The port on which Patroni should listen. + Changing this value causes PostgreSQL to restart. + format: int32 + minimum: 1024 + type: integer + switchover: + description: Switchover gives options to perform ad hoc switchovers + in a PostgresCluster. + properties: + enabled: + description: Whether or not the operator should allow switchovers + in a PostgresCluster + type: boolean + targetInstance: + description: |- + The instance that should become primary during a switchover. This field is + optional when Type is "Switchover" and required when Type is "Failover". + When it is not specified, a healthy replica is automatically selected. + type: string + type: + default: Switchover + description: |- + Type of switchover to perform. Valid options are Switchover and Failover. + "Switchover" changes the primary instance of a healthy PostgresCluster. + "Failover" forces a particular instance to be primary, regardless of other + factors. A TargetInstance must be specified to failover. + NOTE: The Failover type is reserved as the "last resort" case. + enum: + - Switchover + - Failover + maxLength: 15 + type: string + required: + - enabled + type: object + syncPeriodSeconds: + default: 10 + description: |- + The interval for refreshing the leader lock and applying + dynamicConfiguration. Must be less than leaderLeaseDurationSeconds. + Changing this value causes PostgreSQL to restart. + format: int32 + minimum: 1 + type: integer + type: object + paused: + description: |- + Suspends the rollout and reconciliation of changes made to the + PostgresCluster spec. + type: boolean + port: + default: 5432 + description: The port on which PostgreSQL should listen. + format: int32 + minimum: 1024 + type: integer + postGISVersion: + description: |- + The PostGIS extension version installed in the PostgreSQL image. + When image is not set, indicates a PostGIS enabled image will be used. + type: string + postgresVersion: + description: The major version of PostgreSQL installed in the PostgreSQL + image + maximum: 17 + minimum: 11 + type: integer + proxy: + description: The specification of a proxy that connects to PostgreSQL. + properties: + pgBouncer: + description: Defines a PgBouncer proxy and connection pooler. + properties: + affinity: + description: |- + Scheduling constraints of a PgBouncer pod. Changing this value causes + PgBouncer to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + properties: + nodeAffinity: + description: Describes node affinity scheduling rules + for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching + the corresponding nodeSelectorTerm, in the + range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector + terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. + co-locate this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules + (e.g. avoid putting this pod in the same node, zone, + etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + config: + description: |- + Configuration settings for the PgBouncer process. Changes to any of these + values will be automatically reloaded without validation. Be careful, as + you may put PgBouncer into an unusable state. + More info: https://www.pgbouncer.org/usage.html#reload + properties: + databases: + additionalProperties: + type: string + description: |- + PgBouncer database definitions. The key is the database requested by a + client while the value is a libpq-styled connection string. The special + key "*" acts as a fallback. When this field is empty, PgBouncer is + configured with a single "*" entry that connects to the primary + PostgreSQL instance. + More info: https://www.pgbouncer.org/config.html#section-databases + type: object + files: + description: |- + Files to mount under "/etc/pgbouncer". When specified, settings in the + "pgbouncer.ini" file are loaded before all others. From there, other + files may be included by absolute path. Changing these references causes + PgBouncer to restart, but changes to the file contents are automatically + reloaded. + More info: https://www.pgbouncer.org/config.html#include-directive + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root + to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects a field + of the pod: only annotations, labels, + name, namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. + Must not be absolute or contain the + ''..'' path. Must be utf-8 encoded. + The first item of the relative path + must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + global: + additionalProperties: + type: string + description: |- + Settings that apply to the entire PgBouncer process. + More info: https://www.pgbouncer.org/config.html + type: object + users: + additionalProperties: + type: string + description: |- + Connection settings specific to particular users. + More info: https://www.pgbouncer.org/config.html#section-users + type: object + type: object + containers: + description: |- + Custom sidecars for a PgBouncer pod. Changing this value causes + PgBouncer to restart. + items: + description: A single application container that you want + to run within a pod. + properties: + args: + description: |- + Arguments to the entrypoint. + The container image's CMD is used if this is not provided. + Variable references $(VAR_NAME) are expanded using the container's environment. If a variable + cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will + produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless + of whether the variable exists or not. Cannot be updated. + More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + description: |- + Entrypoint array. Not executed within a shell. + The container image's ENTRYPOINT is used if this is not provided. + Variable references $(VAR_NAME) are expanded using the container's environment. If a variable + cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will + produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless + of whether the variable exists or not. Cannot be updated. + More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + description: |- + List of environment variables to set in the container. + Cannot be updated. + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. + Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in + the pod's namespace + properties: + key: + description: The key of the secret to + select from. Must be a valid secret + key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + description: |- + List of sources to populate environment variables in the container. + The keys defined within a source must be a C_IDENTIFIER. All invalid keys + will be reported as an event when the container is starting. When a key exists in multiple + sources, the value associated with the last source will take precedence. + Values defined by an Env with a duplicate key will take precedence. + Cannot be updated. + items: + description: EnvFromSource represents the source of + a set of ConfigMaps or Secrets + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: Optional text to prepend to the name + of each environment variable. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + description: |- + Container image name. + More info: https://kubernetes.io/docs/concepts/containers/images + This field is optional to allow higher level config management to default or override + container images in workload controllers like Deployments and StatefulSets. + type: string + imagePullPolicy: + description: |- + Image pull policy. + One of Always, Never, IfNotPresent. + Defaults to Always if :latest tag is specified, or IfNotPresent otherwise. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/containers/images#updating-images + type: string + lifecycle: + description: |- + Actions that the management system should take in response to container lifecycle events. + Cannot be updated. + properties: + postStart: + description: |- + PostStart is called immediately after a container is created. If the handler fails, + the container is terminated and restarted according to its restart policy. + Other management of the container blocks until the hook completes. + More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks + properties: + exec: + description: Exec specifies a command to execute + in the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the + request. HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom + header to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP + server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + sleep: + description: Sleep represents a duration that + the container should sleep. + properties: + seconds: + description: Seconds is the number of seconds + to sleep. + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + description: |- + Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. + properties: + host: + description: 'Optional: Host name to connect + to, defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + description: |- + PreStop is called immediately before a container is terminated due to an + API request or management event such as liveness/startup probe failure, + preemption, resource contention, etc. The handler is not called if the + container crashes or exits. The Pod's termination grace period countdown begins before the + PreStop hook is executed. Regardless of the outcome of the handler, the + container will eventually terminate within the Pod's termination grace + period (unless delayed by finalizers). Other management of the container blocks until the hook completes + or until the termination grace period is reached. + More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks + properties: + exec: + description: Exec specifies a command to execute + in the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the + request. HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom + header to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP + server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + sleep: + description: Sleep represents a duration that + the container should sleep. + properties: + seconds: + description: Seconds is the number of seconds + to sleep. + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + description: |- + Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. + properties: + host: + description: 'Optional: Host name to connect + to, defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + description: |- + StopSignal defines which signal will be sent to a container when it is being stopped. + If not specified, the default is defined by the container runtime in use. + StopSignal can only be set for Pods with a non-empty .spec.os.name + type: string + type: object + livenessProbe: + description: |- + Periodic probe of container liveness. + Container will be restarted if the probe fails. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + properties: + exec: + description: Exec specifies a command to execute + in the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + description: |- + Minimum consecutive failures for the probe to be considered failed after having succeeded. + Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. + Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + default: "" + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest + (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. + HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom + header to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: |- + Number of seconds after the container has started before liveness probes are initiated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + periodSeconds: + description: |- + How often (in seconds) to perform the probe. + Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: |- + Minimum consecutive successes for the probe to be considered successful after having failed. + Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to + a TCP port. + properties: + host: + description: 'Optional: Host name to connect + to, defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: |- + Optional duration in seconds the pod needs to terminate gracefully upon probe failure. + The grace period is the duration in seconds after the processes running in the pod are sent + a termination signal and the time when the processes are forcibly halted with a kill signal. + Set this value longer than the expected cleanup time for your process. + If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this + value overrides the value provided by the pod spec. + Value must be non-negative integer. The value zero indicates stop immediately via + the kill signal (no opportunity to shut down). + This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: |- + Number of seconds after which the probe times out. + Defaults to 1 second. Minimum value is 1. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + type: object + name: + description: |- + Name of the container specified as a DNS_LABEL. + Each container in a pod must have a unique name (DNS_LABEL). + Cannot be updated. + type: string + ports: + description: |- + List of ports to expose from the container. Not specifying a port here + DOES NOT prevent that port from being exposed. Any port which is + listening on the default "0.0.0.0" address inside a container will be + accessible from the network. + Modifying this array with strategic merge patch may corrupt the data. + For more information See https://github.com/kubernetes/kubernetes/issues/108255. + Cannot be updated. + items: + description: ContainerPort represents a network port + in a single container. + properties: + containerPort: + description: |- + Number of port to expose on the pod's IP address. + This must be a valid port number, 0 < x < 65536. + format: int32 + type: integer + hostIP: + description: What host IP to bind the external + port to. + type: string + hostPort: + description: |- + Number of port to expose on the host. + If specified, this must be a valid port number, 0 < x < 65536. + If HostNetwork is specified, this must match ContainerPort. + Most containers do not need this. + format: int32 + type: integer + name: + description: |- + If specified, this must be an IANA_SVC_NAME and unique within the pod. Each + named port in a pod must have a unique name. Name for the port that can be + referred to by services. + type: string + protocol: + default: TCP + description: |- + Protocol for port. Must be UDP, TCP, or SCTP. + Defaults to "TCP". + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + description: |- + Periodic probe of container service readiness. + Container will be removed from service endpoints if the probe fails. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + properties: + exec: + description: Exec specifies a command to execute + in the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + description: |- + Minimum consecutive failures for the probe to be considered failed after having succeeded. + Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. + Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + default: "" + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest + (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. + HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom + header to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: |- + Number of seconds after the container has started before liveness probes are initiated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + periodSeconds: + description: |- + How often (in seconds) to perform the probe. + Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: |- + Minimum consecutive successes for the probe to be considered successful after having failed. + Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to + a TCP port. + properties: + host: + description: 'Optional: Host name to connect + to, defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: |- + Optional duration in seconds the pod needs to terminate gracefully upon probe failure. + The grace period is the duration in seconds after the processes running in the pod are sent + a termination signal and the time when the processes are forcibly halted with a kill signal. + Set this value longer than the expected cleanup time for your process. + If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this + value overrides the value provided by the pod spec. + Value must be non-negative integer. The value zero indicates stop immediately via + the kill signal (no opportunity to shut down). + This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: |- + Number of seconds after which the probe times out. + Defaults to 1 second. Minimum value is 1. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + type: object + resizePolicy: + description: Resources resize policy for the container. + items: + description: ContainerResizePolicy represents resource + resize policy for the container. + properties: + resourceName: + description: |- + Name of the resource to which this resource resize policy applies. + Supported values: cpu, memory. + type: string + restartPolicy: + description: |- + Restart policy to apply when specified resource is resized. + If not specified, it defaults to NotRequired. + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + description: |- + Compute Resources required by this container. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + restartPolicy: + description: |- + RestartPolicy defines the restart behavior of individual containers in a pod. + This field may only be set for init containers, and the only allowed value is "Always". + For non-init containers or when this field is not specified, + the restart behavior is defined by the Pod's restart policy and the container type. + Setting the RestartPolicy as "Always" for the init container will have the following effect: + this init container will be continually restarted on + exit until all regular containers have terminated. Once all regular + containers have completed, all init containers with restartPolicy "Always" + will be shut down. This lifecycle differs from normal init containers and + is often referred to as a "sidecar" container. Although this init + container still starts in the init container sequence, it does not wait + for the container to complete before proceeding to the next init + container. Instead, the next init container starts immediately after this + init container is started, or after any startupProbe has successfully + completed. + type: string + securityContext: + description: |- + SecurityContext defines the security options the container should be run with. + If set, the fields of SecurityContext override the equivalent fields of PodSecurityContext. + More info: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + properties: + allowPrivilegeEscalation: + description: |- + AllowPrivilegeEscalation controls whether a process can gain more + privileges than its parent process. This bool directly controls if + the no_new_privs flag will be set on the container process. + AllowPrivilegeEscalation is true always when the container is: + 1) run as Privileged + 2) has CAP_SYS_ADMIN + Note that this field cannot be set when spec.os.name is windows. + type: boolean + appArmorProfile: + description: |- + appArmorProfile is the AppArmor options to use by this container. If set, this profile + overrides the pod's appArmorProfile. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile loaded on the node that should be used. + The profile must be preconfigured on the node to work. + Must match the loaded name of the profile. + Must be set if and only if type is "Localhost". + type: string + type: + description: |- + type indicates which kind of AppArmor profile will be applied. + Valid options are: + Localhost - a profile pre-loaded on the node. + RuntimeDefault - the container runtime's default profile. + Unconfined - no AppArmor enforcement. + type: string + required: + - type + type: object + capabilities: + description: |- + The capabilities to add/drop when running containers. + Defaults to the default set of capabilities granted by the container runtime. + Note that this field cannot be set when spec.os.name is windows. + properties: + add: + description: Added capabilities + items: + description: Capability represent POSIX capabilities + type + type: string + type: array + x-kubernetes-list-type: atomic + drop: + description: Removed capabilities + items: + description: Capability represent POSIX capabilities + type + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + description: |- + Run container in privileged mode. + Processes in privileged containers are essentially equivalent to root on the host. + Defaults to false. + Note that this field cannot be set when spec.os.name is windows. + type: boolean + procMount: + description: |- + procMount denotes the type of proc mount to use for the containers. + The default value is Default which uses the container runtime defaults for + readonly paths and masked paths. + This requires the ProcMountType feature flag to be enabled. + Note that this field cannot be set when spec.os.name is windows. + type: string + readOnlyRootFilesystem: + description: |- + Whether this container has a read-only root filesystem. + Default is false. + Note that this field cannot be set when spec.os.name is windows. + type: boolean + runAsGroup: + description: |- + The GID to run the entrypoint of the container process. + Uses runtime default if unset. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + runAsNonRoot: + description: |- + Indicates that the container must run as a non-root user. + If true, the Kubelet will validate the image at runtime to ensure that it + does not run as UID 0 (root) and fail to start the container if it does. + If unset or false, no such validation will be performed. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + type: boolean + runAsUser: + description: |- + The UID to run the entrypoint of the container process. + Defaults to user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + seLinuxOptions: + description: |- + The SELinux context to be applied to the container. + If unspecified, the container runtime will allocate a random SELinux context for each + container. May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + properties: + level: + description: Level is SELinux level label that + applies to the container. + type: string + role: + description: Role is a SELinux role label that + applies to the container. + type: string + type: + description: Type is a SELinux type label that + applies to the container. + type: string + user: + description: User is a SELinux user label that + applies to the container. + type: string + type: object + seccompProfile: + description: |- + The seccomp options to use by this container. If seccomp options are + provided at both the pod & container level, the container options + override the pod options. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile defined in a file on the node should be used. + The profile must be preconfigured on the node to work. + Must be a descending path, relative to the kubelet's configured seccomp profile location. + Must be set if type is "Localhost". Must NOT be set for any other type. + type: string + type: + description: |- + type indicates which kind of seccomp profile will be applied. + Valid options are: + + Localhost - a profile defined in a file on the node should be used. + RuntimeDefault - the container runtime default profile should be used. + Unconfined - no profile should be applied. + type: string + required: + - type + type: object + windowsOptions: + description: |- + The Windows specific settings applied to all containers. + If unspecified, the options from the PodSecurityContext will be used. + If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is linux. + properties: + gmsaCredentialSpec: + description: |- + GMSACredentialSpec is where the GMSA admission webhook + (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the + GMSA credential spec named by the GMSACredentialSpecName field. + type: string + gmsaCredentialSpecName: + description: GMSACredentialSpecName is the name + of the GMSA credential spec to use. + type: string + hostProcess: + description: |- + HostProcess determines if a container should be run as a 'Host Process' container. + All of a Pod's containers must have the same effective HostProcess value + (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). + In addition, if HostProcess is true then HostNetwork must also be set to true. + type: boolean + runAsUserName: + description: |- + The UserName in Windows to run the entrypoint of the container process. + Defaults to the user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + type: string + type: object + type: object + startupProbe: + description: |- + StartupProbe indicates that the Pod has successfully initialized. + If specified, no other probes are executed until this completes successfully. + If this probe fails, the Pod will be restarted, just as if the livenessProbe failed. + This can be used to provide different probe parameters at the beginning of a Pod's lifecycle, + when it might take a long time to load data or warm a cache, than during steady-state operation. + This cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + properties: + exec: + description: Exec specifies a command to execute + in the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + description: |- + Minimum consecutive failures for the probe to be considered failed after having succeeded. + Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. + Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + default: "" + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest + (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. + HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom + header to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: |- + Number of seconds after the container has started before liveness probes are initiated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + periodSeconds: + description: |- + How often (in seconds) to perform the probe. + Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: |- + Minimum consecutive successes for the probe to be considered successful after having failed. + Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to + a TCP port. + properties: + host: + description: 'Optional: Host name to connect + to, defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: |- + Optional duration in seconds the pod needs to terminate gracefully upon probe failure. + The grace period is the duration in seconds after the processes running in the pod are sent + a termination signal and the time when the processes are forcibly halted with a kill signal. + Set this value longer than the expected cleanup time for your process. + If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this + value overrides the value provided by the pod spec. + Value must be non-negative integer. The value zero indicates stop immediately via + the kill signal (no opportunity to shut down). + This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: |- + Number of seconds after which the probe times out. + Defaults to 1 second. Minimum value is 1. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + type: object + stdin: + description: |- + Whether this container should allocate a buffer for stdin in the container runtime. If this + is not set, reads from stdin in the container will always result in EOF. + Default is false. + type: boolean + stdinOnce: + description: |- + Whether the container runtime should close the stdin channel after it has been opened by + a single attach. When stdin is true the stdin stream will remain open across multiple attach + sessions. If stdinOnce is set to true, stdin is opened on container start, is empty until the + first client attaches to stdin, and then remains open and accepts data until the client disconnects, + at which time stdin is closed and remains closed until the container is restarted. If this + flag is false, a container processes that reads from stdin will never receive an EOF. + Default is false + type: boolean + terminationMessagePath: + description: |- + Optional: Path at which the file to which the container's termination message + will be written is mounted into the container's filesystem. + Message written is intended to be brief final status, such as an assertion failure message. + Will be truncated by the node if greater than 4096 bytes. The total message length across + all containers will be limited to 12kb. + Defaults to /dev/termination-log. + Cannot be updated. + type: string + terminationMessagePolicy: + description: |- + Indicate how the termination message should be populated. File will use the contents of + terminationMessagePath to populate the container status message on both success and failure. + FallbackToLogsOnError will use the last chunk of container log output if the termination + message file is empty and the container exited with an error. + The log output is limited to 2048 bytes or 80 lines, whichever is smaller. + Defaults to File. + Cannot be updated. + type: string + tty: + description: |- + Whether this container should allocate a TTY for itself, also requires 'stdin' to be true. + Default is false. + type: boolean + volumeDevices: + description: volumeDevices is the list of block devices + to be used by the container. + items: + description: volumeDevice describes a mapping of a + raw block device within a container. + properties: + devicePath: + description: devicePath is the path inside of + the container that the device will be mapped + to. + type: string + name: + description: name must match the name of a persistentVolumeClaim + in the pod + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + description: |- + Pod volumes to mount into the container's filesystem. + Cannot be updated. + items: + description: VolumeMount describes a mounting of a + Volume within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + description: |- + Container's working directory. + If not specified, the container runtime's default will be used, which + might be configured in the container image. + Cannot be updated. + type: string + required: + - name + type: object + type: array + customTLSSecret: + description: |- + A secret projection containing a certificate and key with which to encrypt + connections to PgBouncer. The "tls.crt", "tls.key", and "ca.crt" paths must + be PEM-encoded certificates and keys. Changing this value causes PgBouncer + to restart. + More info: https://kubernetes.io/docs/concepts/configuration/secret/#projection-of-secret-keys-to-specific-paths + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + image: + description: |- + Name of a container image that can run PgBouncer 1.15 or newer. Changing + this value causes PgBouncer to restart. The image may also be set using + the RELATED_IMAGE_PGBOUNCER environment variable. + More info: https://kubernetes.io/docs/concepts/containers/images + type: string + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + Minimum number of pods that should be available at a time. + Defaults to one when the replicas field is greater than one. + x-kubernetes-int-or-string: true + port: + default: 5432 + description: |- + Port on which PgBouncer should listen for client connections. Changing + this value causes PgBouncer to restart. + format: int32 + minimum: 1024 + type: integer + priorityClassName: + description: |- + Priority class name for the pgBouncer pod. Changing this value causes + PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + type: string + replicas: + default: 1 + description: Number of desired PgBouncer pods. + format: int32 + minimum: 0 + type: integer + resources: + description: |- + Compute resources of a PgBouncer container. Changing this value causes + PgBouncer to restart. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Specification of the service that exposes PgBouncer. + properties: + externalTrafficPolicy: + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' + enum: + - Cluster + - Local + maxLength: 10 + type: string + internalTrafficPolicy: + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' + enum: + - Cluster + - Local + maxLength: 10 + type: string + ipFamilies: + items: + description: |- + IPFamily represents the IP Family (IPv4 or IPv6). This type is used + to express the family of an IP expressed by a type (e.g. service.spec.ipFamilies). + enum: + - IPv4 + - IPv6 + type: string + type: array + ipFamilyPolicy: + description: 'More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/' + enum: + - SingleStack + - PreferDualStack + - RequireDualStack + type: string + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + nodePort: + description: |- + The port on which this service is exposed when type is NodePort or + LoadBalancer. Value must be in-range and not in use or the operation will + fail. If unspecified, a port will be allocated if this Service requires one. + - https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport + format: int32 + type: integer + type: + default: ClusterIP + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types' + enum: + - ClusterIP + - NodePort + - LoadBalancer + maxLength: 15 + type: string + type: object + sidecars: + description: Configuration for pgBouncer sidecar containers + properties: + pgbouncerConfig: + description: Defines the configuration for the pgBouncer + config sidecar container + properties: + resources: + description: Resource requirements for a sidecar container + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + type: object + tolerations: + description: |- + Tolerations of a PgBouncer pod. Changing this value causes PgBouncer to + restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + topologySpreadConstraints: + description: |- + Topology spread constraints of a PgBouncer pod. Changing this value causes + PgBouncer to restart. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/ + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + volumes: + description: PGBouncerVolumesSpec defines the configuration + for pgBouncer additional volumes + properties: + additional: + description: Additional pre-existing volumes to add to + the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + required: + - pgBouncer + type: object + replicaService: + description: Specification of the service that exposes PostgreSQL + replica instances + properties: + externalTrafficPolicy: + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' + enum: + - Cluster + - Local + maxLength: 10 + type: string + internalTrafficPolicy: + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' + enum: + - Cluster + - Local + maxLength: 10 + type: string + ipFamilies: + items: + description: |- + IPFamily represents the IP Family (IPv4 or IPv6). This type is used + to express the family of an IP expressed by a type (e.g. service.spec.ipFamilies). + enum: + - IPv4 + - IPv6 + type: string + type: array + ipFamilyPolicy: + description: 'More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/' + enum: + - SingleStack + - PreferDualStack + - RequireDualStack + type: string + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + nodePort: + description: |- + The port on which this service is exposed when type is NodePort or + LoadBalancer. Value must be in-range and not in use or the operation will + fail. If unspecified, a port will be allocated if this Service requires one. + - https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport + format: int32 + type: integer + type: + default: ClusterIP + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types' + enum: + - ClusterIP + - NodePort + - LoadBalancer + maxLength: 15 + type: string + type: object + service: + description: Specification of the service that exposes the PostgreSQL + primary instance. + properties: + externalTrafficPolicy: + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' + enum: + - Cluster + - Local + maxLength: 10 + type: string + internalTrafficPolicy: + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' + enum: + - Cluster + - Local + maxLength: 10 + type: string + ipFamilies: + items: + description: |- + IPFamily represents the IP Family (IPv4 or IPv6). This type is used + to express the family of an IP expressed by a type (e.g. service.spec.ipFamilies). + enum: + - IPv4 + - IPv6 + type: string + type: array + ipFamilyPolicy: + description: 'More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/' + enum: + - SingleStack + - PreferDualStack + - RequireDualStack + type: string + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + nodePort: + description: |- + The port on which this service is exposed when type is NodePort or + LoadBalancer. Value must be in-range and not in use or the operation will + fail. If unspecified, a port will be allocated if this Service requires one. + - https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport + format: int32 + type: integer + type: + default: ClusterIP + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types' + enum: + - ClusterIP + - NodePort + - LoadBalancer + maxLength: 15 + type: string + type: object + shutdown: + description: |- + Whether or not the PostgreSQL cluster should be stopped. + When this is true, workloads are scaled to zero and CronJobs + are suspended. + Other resources, such as Services and Volumes, remain in place. + type: boolean + standby: + description: Run this cluster as a read-only copy of an existing cluster + or archive. + properties: + enabled: + default: true + description: |- + Whether or not the PostgreSQL cluster should be read-only. When this is + true, WAL files are applied from a pgBackRest repository or another + PostgreSQL server. + type: boolean + host: + description: Network address of the PostgreSQL server to follow + via streaming replication. + type: string + port: + description: Network port of the PostgreSQL server to follow via + streaming replication. + format: int32 + minimum: 1024 + type: integer + repoName: + description: The name of the pgBackRest repository to follow for + WAL files. + pattern: ^repo[1-4] + type: string + type: object + supplementalGroups: + description: |- + A list of group IDs applied to the process of a container. These can be + useful when accessing shared file systems with constrained permissions. + More info: https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context + items: + format: int64 + maximum: 2147483647 + minimum: 1 + type: integer + type: array + userInterface: + description: The specification of a user interface that connects to + PostgreSQL. -- DEPRECATED + properties: + pgAdmin: + description: Defines a pgAdmin user interface. + properties: + affinity: + description: |- + Scheduling constraints of a pgAdmin pod. Changing this value causes + pgAdmin to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + properties: + nodeAffinity: + description: Describes node affinity scheduling rules + for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching + the corresponding nodeSelectorTerm, in the + range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector + terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. + co-locate this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules + (e.g. avoid putting this pod in the same node, zone, + etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + config: + description: |- + Configuration settings for the pgAdmin process. Changes to any of these + values will be loaded without validation. Be careful, as + you may put pgAdmin into an unusable state. + properties: + files: + description: |- + Files allows the user to mount projected volumes into the pgAdmin + container so that files can be referenced by pgAdmin as needed. + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root + to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing + the pod field + properties: + fieldRef: + description: 'Required: Selects a field + of the pod: only annotations, labels, + name, namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema + the FieldPath is written in terms + of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to + select in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. + Must not be absolute or contain the + ''..'' path. Must be utf-8 encoded. + The first item of the relative path + must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output + format of the exposed resources, + defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to + select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether + the Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information + about the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + ldapBindPassword: + description: |- + A Secret containing the value for the LDAP_BIND_PASSWORD setting. + More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html + properties: + key: + description: Name of the data field within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") + name: + description: Name of the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + optional: + description: Whether or not the Secret or its data + must be defined. Defaults to false. + type: boolean + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + settings: + description: |- + Settings for the pgAdmin server process. Keys should be uppercase and + values must be constants. + More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + dataVolumeClaimSpec: + description: |- + Defines a PersistentVolumeClaim for pgAdmin data. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over volumes to + consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to the + PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + image: + description: |- + Name of a container image that can run pgAdmin 4. Changing this value causes + pgAdmin to restart. The image may also be set using the RELATED_IMAGE_PGADMIN + environment variable. + More info: https://kubernetes.io/docs/concepts/containers/images + type: string + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + priorityClassName: + description: |- + Priority class name for the pgAdmin pod. Changing this value causes pgAdmin + to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + type: string + replicas: + default: 1 + description: Number of desired pgAdmin pods. + format: int32 + maximum: 1 + minimum: 0 + type: integer + resources: + description: |- + Compute resources of a pgAdmin container. Changing this value causes + pgAdmin to restart. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + service: + description: Specification of the service that exposes pgAdmin. + properties: + externalTrafficPolicy: + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' + enum: + - Cluster + - Local + maxLength: 10 + type: string + internalTrafficPolicy: + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' + enum: + - Cluster + - Local + maxLength: 10 + type: string + ipFamilies: + items: + description: |- + IPFamily represents the IP Family (IPv4 or IPv6). This type is used + to express the family of an IP expressed by a type (e.g. service.spec.ipFamilies). + enum: + - IPv4 + - IPv6 + type: string + type: array + ipFamilyPolicy: + description: 'More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/' + enum: + - SingleStack + - PreferDualStack + - RequireDualStack + type: string + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + nodePort: + description: |- + The port on which this service is exposed when type is NodePort or + LoadBalancer. Value must be in-range and not in use or the operation will + fail. If unspecified, a port will be allocated if this Service requires one. + - https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport + format: int32 + type: integer + type: + default: ClusterIP + description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types' + enum: + - ClusterIP + - NodePort + - LoadBalancer + maxLength: 15 + type: string + type: object + tolerations: + description: |- + Tolerations of a pgAdmin pod. Changing this value causes pgAdmin to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + topologySpreadConstraints: + description: |- + Topology spread constraints of a pgAdmin pod. Changing this value causes + pgAdmin to restart. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/ + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + required: + - dataVolumeClaimSpec + type: object + required: + - pgAdmin + type: object + x-kubernetes-validations: + - message: userInterface not available in v1 + rule: type(self) == null_type + users: + description: |- + Users to create inside PostgreSQL and the databases they should access. + The default creates one user that can access one database matching the + PostgresCluster name. An empty list creates no users. Removing a user + from this list does NOT drop the user nor revoke their access. + items: + properties: + databases: + description: |- + Databases to which this user can connect and create objects. Removing a + database from this list does NOT revoke access. This field is ignored for + the "postgres" user. + items: + maxLength: 63 + minLength: 1 + type: string + type: array + x-kubernetes-list-type: set + name: + description: |- + The name of this PostgreSQL user. The value may contain only lowercase + letters, numbers, and hyphen so that it fits into Kubernetes metadata. + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + options: + description: |- + ALTER ROLE options except for PASSWORD. This field is ignored for the + "postgres" user. + More info: https://www.postgresql.org/docs/current/role-attributes.html + maxLength: 200 + pattern: ^[^;]*$ + type: string + x-kubernetes-validations: + - message: cannot assign password + rule: '!self.matches("(?i:PASSWORD)")' + - message: cannot contain comments + rule: '!self.matches("(?:--|/[*]|[*]/)")' + password: + description: Properties of the password generated for this user. + properties: + type: + default: ASCII + description: |- + Type of password to generate. Defaults to ASCII. Valid options are ASCII + and AlphaNumeric. + "ASCII" passwords contain letters, numbers, and symbols from the US-ASCII character set. + "AlphaNumeric" passwords contain letters and numbers from the US-ASCII character set. + enum: + - ASCII + - AlphaNumeric + maxLength: 15 + type: string + required: + - type + type: object + required: + - name + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + required: + - instances + - postgresVersion + type: object + status: + description: PostgresClusterStatus defines the observed state of PostgresCluster + properties: + conditions: + description: |- + conditions represent the observations of postgrescluster's current state. + Known .status.conditions.type are: "PersistentVolumeResizing", + "Progressing", "ProxyAvailable" + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + databaseInitSQL: + description: DatabaseInitSQL state of custom database initialization + in the cluster + type: string + databaseRevision: + description: Identifies the databases that have been installed into + PostgreSQL. + type: string + instances: + description: Current state of PostgreSQL instances. + items: + properties: + desiredPGDataVolume: + additionalProperties: + type: string + description: Desired Size of the pgData volume + type: object + name: + type: string + readyReplicas: + description: Total number of ready pods. + format: int32 + type: integer + replicas: + description: Total number of pods. + format: int32 + type: integer + updatedReplicas: + description: Total number of pods that have the desired specification. + format: int32 + type: integer + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + monitoring: + description: Current state of PostgreSQL cluster monitoring tool configuration + properties: + exporterConfiguration: + type: string + type: object + observedGeneration: + description: observedGeneration represents the .metadata.generation + on which the status was based. + format: int64 + minimum: 0 + type: integer + patroni: + properties: + switchover: + description: Tracks the execution of the switchover requests. + type: string + switchoverTimeline: + description: Tracks the current timeline during switchovers + format: int64 + type: integer + systemIdentifier: + description: The PostgreSQL system identifier reported by Patroni. + type: string + type: object + pgbackrest: + description: Status information for pgBackRest + properties: + manualBackup: + description: Status information for manual backups + properties: + active: + description: The number of actively running manual backup + Pods. + format: int32 + type: integer + completionTime: + description: |- + Represents the time the manual backup Job was determined by the Job controller + to be completed. This field is only set if the backup completed successfully. + Additionally, it is represented in RFC3339 form and is in UTC. + format: date-time + type: string + failed: + description: The number of Pods for the manual backup Job + that reached the "Failed" phase. + format: int32 + type: integer + finished: + description: |- + Specifies whether or not the Job is finished executing (does not indicate success or + failure). + type: boolean + id: + description: |- + A unique identifier for the manual backup as provided using the "pgbackrest-backup" + annotation when initiating a backup. + type: string + startTime: + description: |- + Represents the time the manual backup Job was acknowledged by the Job controller. + It is represented in RFC3339 form and is in UTC. + format: date-time + type: string + succeeded: + description: The number of Pods for the manual backup Job + that reached the "Succeeded" phase. + format: int32 + type: integer + required: + - finished + - id + type: object + repoHost: + description: Status information for the pgBackRest dedicated repository + host + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + ready: + description: Whether or not the pgBackRest repository host + is ready for use + type: boolean + type: object + repos: + description: Status information for pgBackRest repositories + items: + description: RepoStatus the status of a pgBackRest repository + properties: + bound: + description: Whether or not the pgBackRest repository PersistentVolumeClaim + is bound to a volume + type: boolean + name: + description: The name of the pgBackRest repository + type: string + replicaCreateBackupComplete: + description: |- + ReplicaCreateBackupReady indicates whether a backup exists in the repository as needed + to bootstrap replicas. + type: boolean + repoOptionsHash: + description: |- + A hash of the required fields in the spec for defining an Azure, GCS or S3 repository, + Utilized to detect changes to these fields and then execute pgBackRest stanza-create + commands accordingly. + type: string + stanzaCreated: + description: Specifies whether or not a stanza has been + successfully created for the repository + type: boolean + volume: + description: The name of the volume the containing the pgBackRest + repository + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + restore: + description: Status information for in-place restores + properties: + active: + description: The number of actively running manual backup + Pods. + format: int32 + type: integer + completionTime: + description: |- + Represents the time the manual backup Job was determined by the Job controller + to be completed. This field is only set if the backup completed successfully. + Additionally, it is represented in RFC3339 form and is in UTC. + format: date-time + type: string + failed: + description: The number of Pods for the manual backup Job + that reached the "Failed" phase. + format: int32 + type: integer + finished: + description: |- + Specifies whether or not the Job is finished executing (does not indicate success or + failure). + type: boolean + id: + description: |- + A unique identifier for the manual backup as provided using the "pgbackrest-backup" + annotation when initiating a backup. + type: string + startTime: + description: |- + Represents the time the manual backup Job was acknowledged by the Job controller. + It is represented in RFC3339 form and is in UTC. + format: date-time + type: string + succeeded: + description: The number of Pods for the manual backup Job + that reached the "Succeeded" phase. + format: int32 + type: integer + required: + - finished + - id + type: object + scheduledBackups: + description: Status information for scheduled backups + items: + properties: + active: + description: The number of actively running manual backup + Pods. + format: int32 + type: integer + completionTime: + description: |- + Represents the time the manual backup Job was determined by the Job controller + to be completed. This field is only set if the backup completed successfully. + Additionally, it is represented in RFC3339 form and is in UTC. + format: date-time + type: string + cronJobName: + description: The name of the associated pgBackRest scheduled + backup CronJob + type: string + failed: + description: The number of Pods for the manual backup Job + that reached the "Failed" phase. + format: int32 + type: integer + repo: + description: The name of the associated pgBackRest repository + type: string + startTime: + description: |- + Represents the time the manual backup Job was acknowledged by the Job controller. + It is represented in RFC3339 form and is in UTC. + format: date-time + type: string + succeeded: + description: The number of Pods for the manual backup Job + that reached the "Succeeded" phase. + format: int32 + type: integer + type: + description: The pgBackRest backup type for this Job + type: string + type: object + type: array + type: object + postgresVersion: + description: |- + Stores the current PostgreSQL major version following a successful + major PostgreSQL upgrade. + type: integer + proxy: + description: Current state of the PostgreSQL proxy. + properties: + pgBouncer: + properties: + postgresRevision: + description: |- + Identifies the revision of PgBouncer assets that have been installed into + PostgreSQL. + type: string + readyReplicas: + description: Total number of ready pods. + format: int32 + type: integer + replicas: + description: Total number of non-terminated pods. + format: int32 + type: integer + type: object + type: object + registrationRequired: + properties: + pgoVersion: + type: string + type: object + startupInstance: + description: |- + The instance that should be started first when bootstrapping and/or starting a + PostgresCluster. + type: string + startupInstanceSet: + description: The instance set associated with the startupInstance + type: string + tokenRequired: + type: string + userInterface: + description: Current state of the PostgreSQL user interface. + properties: + pgAdmin: + description: The state of the pgAdmin user interface. + properties: + usersRevision: + description: Hash that indicates which users have been installed + into pgAdmin. + type: string + type: object + type: object + usersRevision: + description: Identifies the users that have been installed into PostgreSQL. + type: string + type: object + type: object + served: true + storage: false + subresources: + status: {} + - name: v1beta1 + schema: + openAPIV3Schema: + description: PostgresCluster is the Schema for the postgresclusters API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: PostgresClusterSpec defines the desired state of PostgresCluster + properties: + authentication: + description: Authentication settings for the PostgreSQL server + properties: + rules: + description: |- + Postgres compares every new connection to these rules in the order they are + defined. The first rule that matches determines if and how the connection + must then authenticate. Connections that match no rules are disconnected. + + When this is omitted or empty, Postgres accepts encrypted connections to any + database from users that have a password. To refuse all network connections, + set this to one rule that matches "host" connections to the "reject" method. + + More info: https://www.postgresql.org/docs/current/auth-pg-hba-conf.html + items: + properties: + connection: + description: |- + The connection transport this rule matches. Typical values are: + 1. "host" for network connections that may or may not be encrypted. + 2. "hostssl" for network connections encrypted using TLS. + 3. "hostgssenc" for network connections encrypted using GSSAPI. + maxLength: 20 + minLength: 1 + pattern: ^[-a-z0-9]+$ + type: string + databases: + description: Which databases this rule matches. When omitted + or empty, this rule matches all databases. + items: + maxLength: 63 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + hba: + description: One line of the "pg_hba.conf" file. Changes + to this value will be automatically reloaded without validation. + maxLength: 100 + minLength: 1 + pattern: ^[[:print:]]+$ + type: string + x-kubernetes-validations: + - message: cannot include other files + rule: '!self.trim().startsWith("include")' + method: + description: |- + The authentication method to use when a connection matches this rule. + The special value "reject" refuses connections that match this rule. + + More info: https://www.postgresql.org/docs/current/auth-methods.html + maxLength: 20 + minLength: 1 + pattern: ^[-a-z0-9]+$ + type: string + x-kubernetes-validations: + - message: the "trust" method is unsafe + rule: self != "trust" + options: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: Additional settings for this rule or its authentication + method. + maxProperties: 20 + type: object + x-kubernetes-map-type: atomic + users: + description: Which user names this rule matches. When omitted + or empty, this rule matches all users. + items: + maxLength: 63 + minLength: 1 + type: string + maxItems: 20 + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: '"hba" cannot be combined with other fields' + rule: '[has(self.hba), has(self.connection) || has(self.databases) + || has(self.method) || has(self.options) || has(self.users)].exists_one(b,b)' + - message: '"connection" and "method" are required' + rule: has(self.hba) || (has(self.connection) && has(self.method)) + - message: the "ldap" method requires an "ldapbasedn", "ldapprefix", + or "ldapsuffix" option + rule: has(self.hba) || self.method != "ldap" || (has(self.options) + && ["ldapbasedn","ldapprefix","ldapsuffix"].exists(k, k + in self.options)) + - message: cannot use "ldapbasedn", "ldapbinddn", "ldapbindpasswd", + "ldapsearchattribute", or "ldapsearchfilter" options with + "ldapprefix" or "ldapsuffix" options + rule: has(self.hba) || self.method != "ldap" || !has(self.options) + || 2 > size([["ldapprefix","ldapsuffix"], ["ldapbasedn","ldapbinddn","ldapbindpasswd","ldapsearchattribute","ldapsearchfilter"]].filter(a, + a.exists(k, k in self.options))) + - message: the "radius" method requires "radiusservers" and + "radiussecrets" options + rule: has(self.hba) || self.method != "radius" || (has(self.options) + && ["radiusservers","radiussecrets"].all(k, k in self.options)) + maxItems: 10 + type: array + x-kubernetes-list-type: atomic + type: object + backups: + description: PostgreSQL backup configuration + properties: + pgbackrest: + description: pgBackRest archive configuration + properties: + configuration: + description: |- + Projected volumes containing custom pgBackRest configuration. These files are mounted + under "/etc/pgbackrest/conf.d" alongside any pgBackRest configuration generated by the + PostgreSQL Operator: + https://pgbackrest.org/configuration.html + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root + to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path. + Must be utf-8 encoded. The first item of + the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the + Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about + the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + global: + additionalProperties: + type: string + description: |- + Global pgBackRest configuration settings. These settings are included in the "global" + section of the pgBackRest configuration generated by the PostgreSQL Operator, and then + mounted under "/etc/pgbackrest/conf.d": + https://pgbackrest.org/configuration.html + type: object + image: + description: |- + The image name to use for pgBackRest containers. Utilized to run + pgBackRest repository hosts and backups. The image may also be set using + the RELATED_IMAGE_PGBACKREST environment variable + type: string + jobs: + description: Jobs field allows configuration for all backup + jobs + properties: + affinity: + description: |- + Scheduling constraints of pgBackRest backup Job pods. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + properties: + nodeAffinity: + description: Describes node affinity scheduling rules + for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching + the corresponding nodeSelectorTerm, in + the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector + terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules + (e.g. co-locate this pod in the same node, zone, + etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched + WeightedPodAffinityTerm fields are added per-node + to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling + rules (e.g. avoid putting this pod in the same node, + zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched + WeightedPodAffinityTerm fields are added per-node + to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + priorityClassName: + description: |- + Priority class name for the pgBackRest backup Job pods. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + type: string + resources: + description: |- + Resource limits for backup jobs. Includes manual, scheduled and replica + create backups + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + tolerations: + description: |- + Tolerations of pgBackRest backup Job pods. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + ttlSecondsAfterFinished: + description: |- + Limit the lifetime of a Job that has finished. + More info: https://kubernetes.io/docs/concepts/workloads/controllers/job + format: int32 + minimum: 60 + type: integer + volumes: + description: Volumes to add to Backup Job Pods + properties: + additional: + description: Additional pre-existing volumes to add + to the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + manual: + description: Defines details for manual pgBackRest backup + Jobs + properties: + options: + description: |- + Command line options to include when running the pgBackRest backup command. + https://pgbackrest.org/command.html#command-backup + items: + type: string + type: array + repoName: + description: The name of the pgBackRest repo to run the + backup command against. + pattern: ^repo[1-4] + type: string + required: + - repoName + type: object + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + repoHost: + description: |- + Defines configuration for a pgBackRest dedicated repository host. This section is only + applicable if at least one "volume" (i.e. PVC-based) repository is defined in the "repos" + section, therefore enabling a dedicated repository host Deployment. + properties: + affinity: + description: |- + Scheduling constraints of the Dedicated repo host pod. + Changing this value causes repo host to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + properties: + nodeAffinity: + description: Describes node affinity scheduling rules + for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching + the corresponding nodeSelectorTerm, in + the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector + terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules + (e.g. co-locate this pod in the same node, zone, + etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched + WeightedPodAffinityTerm fields are added per-node + to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling + rules (e.g. avoid putting this pod in the same node, + zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched + WeightedPodAffinityTerm fields are added per-node + to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + priorityClassName: + description: |- + Priority class name for the pgBackRest repo host pod. Changing this value + causes PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + type: string + resources: + description: Resource requirements for a pgBackRest repository + host + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + sshConfigMap: + description: |- + ConfigMap containing custom SSH configuration. + Deprecated: Repository hosts use mTLS for encryption, authentication, and authorization. + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + sshSecret: + description: |- + Secret containing custom SSH keys. + Deprecated: Repository hosts use mTLS for encryption, authentication, and authorization. + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + tolerations: + description: |- + Tolerations of a PgBackRest repo host pod. Changing this value causes a restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + topologySpreadConstraints: + description: |- + Topology spread constraints of a Dedicated repo host pod. Changing this + value causes the repo host to restart. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/ + items: + description: TopologySpreadConstraint specifies how + to spread matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + volumes: + description: Volumes to add to the Repo Host Pod + properties: + additional: + description: Additional pre-existing volumes to add + to the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + type: object + repos: + description: Defines a pgBackRest repository + items: + description: PGBackRestRepo represents a pgBackRest repository. Only + one of its members may be specified. + properties: + azure: + description: Represents a pgBackRest repository that + is created using Azure storage + properties: + container: + description: The Azure container utilized for the + repository + type: string + required: + - container + type: object + gcs: + description: Represents a pgBackRest repository that + is created using Google Cloud Storage + properties: + bucket: + description: The GCS bucket utilized for the repository + type: string + required: + - bucket + type: object + name: + description: The name of the repository + pattern: ^repo[1-4] + type: string + s3: + description: |- + RepoS3 represents a pgBackRest repository that is created using AWS S3 (or S3-compatible) + storage + properties: + bucket: + description: The S3 bucket utilized for the repository + type: string + endpoint: + description: A valid endpoint corresponding to the + specified region + type: string + region: + description: The region corresponding to the S3 + bucket + type: string + required: + - bucket + - endpoint + - region + type: object + schedules: + description: |- + Defines the schedules for the pgBackRest backups + Full, Differential and Incremental backup types are supported: + https://pgbackrest.org/user-guide.html#concept/backup + properties: + differential: + description: |- + Defines the Cron schedule for a differential pgBackRest backup. + Follows the standard Cron schedule syntax: + https://k8s.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax + minLength: 6 + type: string + full: + description: |- + Defines the Cron schedule for a full pgBackRest backup. + Follows the standard Cron schedule syntax: + https://k8s.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax + minLength: 6 + type: string + incremental: + description: |- + Defines the Cron schedule for an incremental pgBackRest backup. + Follows the standard Cron schedule syntax: + https://k8s.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax + minLength: 6 + type: string + type: object + volume: + description: Represents a pgBackRest repository that + is created using a PersistentVolumeClaim + properties: + volumeClaimSpec: + description: Defines a PersistentVolumeClaim spec + used to create and/or bind a volume + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource + being referenced + type: string + name: + description: Name is the name of resource + being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource + being referenced + type: string + name: + description: Name is the name of resource + being referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over + volumes to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + required: + - volumeClaimSpec + type: object + required: + - name + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + restore: + description: Defines details for performing an in-place restore + using pgBackRest + properties: + affinity: + description: |- + Scheduling constraints of the pgBackRest restore Job. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + properties: + nodeAffinity: + description: Describes node affinity scheduling rules + for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching + the corresponding nodeSelectorTerm, in + the range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector + terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector + requirements by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector + requirements by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that + the selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules + (e.g. co-locate this pod in the same node, zone, + etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched + WeightedPodAffinityTerm fields are added per-node + to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling + rules (e.g. avoid putting this pod in the same node, + zone, etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched + WeightedPodAffinityTerm fields are added per-node + to find the most preferred node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is + a list of label selector requirements. + The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + clusterName: + description: |- + The name of an existing PostgresCluster to use as the data source for the new PostgresCluster. + Defaults to the name of the PostgresCluster being created if not provided. + type: string + clusterNamespace: + description: |- + The namespace of the cluster specified as the data source using the clusterName field. + Defaults to the namespace of the PostgresCluster being created if not provided. + type: string + enabled: + default: false + description: Whether or not in-place pgBackRest restores + are enabled for this PostgresCluster. + type: boolean + options: + description: |- + Command line options to include when running the pgBackRest restore command. + https://pgbackrest.org/command.html#command-restore + items: + type: string + type: array + priorityClassName: + description: |- + Priority class name for the pgBackRest restore Job pod. Changing this + value causes PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + type: string + repoName: + description: |- + The name of the pgBackRest repo within the source PostgresCluster that contains the backups + that should be utilized to perform a pgBackRest restore when initializing the data source + for the new PostgresCluster. + pattern: ^repo[1-4] + type: string + resources: + description: Resource requirements for the pgBackRest + restore Job. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + tolerations: + description: |- + Tolerations of the pgBackRest restore Job. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + volumes: + description: Volumes to add to Restore Job Pods + properties: + additional: + description: Additional pre-existing volumes to add + to the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + required: + - enabled + - repoName + type: object + sidecars: + description: Configuration for pgBackRest sidecar containers + properties: + pgbackrest: + description: Defines the configuration for the pgBackRest + sidecar container + properties: + resources: + description: Resource requirements for a sidecar container + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + pgbackrestConfig: + description: Defines the configuration for the pgBackRest + config sidecar container + properties: + resources: + description: Resource requirements for a sidecar container + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + type: object + required: + - repos + type: object + snapshots: + description: VolumeSnapshot configuration + properties: + volumeSnapshotClassName: + description: Name of the VolumeSnapshotClass that should be + used by VolumeSnapshots + minLength: 1 + type: string + required: + - volumeSnapshotClassName + type: object + type: object + config: + description: General configuration of the PostgreSQL server + properties: + files: + description: Files to mount under "/etc/postgres". + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root to write + the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume file + items: + description: DownwardAPIVolumeFile represents information + to create the file containing the pod field + properties: + fieldRef: + description: 'Required: Selects a field of the + pod: only annotations, labels, name, namespace + and uid are supported.' + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must not + be absolute or contain the ''..'' path. Must + be utf-8 encoded. The first item of the relative + path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret data to + project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a + volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the Secret + or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about the + serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + parameters: + additionalProperties: + anyOf: + - type: integer + - type: string + x-kubernetes-int-or-string: true + description: |- + Configuration parameters for the PostgreSQL server. Some values will + be reloaded without validation and some cause PostgreSQL to restart. + Some values cannot be changed at all. + More info: https://www.postgresql.org/docs/current/runtime-config.html + maxProperties: 50 + type: object + x-kubernetes-map-type: granular + x-kubernetes-validations: + - message: 'cannot change PGDATA path: config_file, data_directory' + rule: '!has(self.config_file) && !has(self.data_directory)' + - message: cannot change external_pid_file + rule: '!has(self.external_pid_file)' + - message: 'cannot change authentication path: hba_file, ident_file' + rule: '!has(self.hba_file) && !has(self.ident_file)' + - message: 'network connectivity is always enabled: listen_addresses' + rule: '!has(self.listen_addresses)' + - message: change port using .spec.port instead + rule: '!has(self.port)' + - message: TLS is always enabled + rule: '!has(self.ssl) && !self.exists(k, k.startsWith("ssl_"))' + - message: domain socket paths cannot be changed + rule: '!self.exists(k, k.startsWith("unix_socket_"))' + - message: wal_level must be "replica" or higher + rule: '!has(self.wal_level) || self.wal_level in ["logical"]' + - message: wal_log_hints are always enabled + rule: '!has(self.wal_log_hints)' + - rule: '!has(self.archive_mode) && !has(self.archive_command) + && !has(self.restore_command)' + - rule: '!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))' + - message: hot_standby is always enabled + rule: '!has(self.hot_standby)' + - rule: '!has(self.synchronous_standby_names)' + - rule: '!has(self.primary_conninfo) && !has(self.primary_slot_name)' + - message: delayed replication is not supported at this time + rule: '!has(self.recovery_min_apply_delay)' + - message: cluster_name is derived from the PostgresCluster name + rule: '!has(self.cluster_name)' + - message: disabling logging_collector is unsafe + rule: '!has(self.logging_collector)' + - message: log_file_mode cannot be changed + rule: '!has(self.log_file_mode)' + type: object + customReplicationTLSSecret: + description: |- + The secret containing the replication client certificates and keys for + secure connections to the PostgreSQL server. It will need to contain the + client TLS certificate, TLS key and the Certificate Authority certificate + with the data keys set to tls.crt, tls.key and ca.crt, respectively. + NOTE: If CustomReplicationClientTLSSecret is provided, CustomTLSSecret + MUST be provided and the ca.crt provided must be the same. + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the Secret or its + key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + customTLSSecret: + description: |- + The secret containing the Certificates and Keys to encrypt PostgreSQL + traffic will need to contain the server TLS certificate, TLS key and the + Certificate Authority certificate with the data keys set to tls.crt, + tls.key and ca.crt, respectively. It will then be mounted as a volume + projection to the '/pgconf/tls' directory. For more information on + Kubernetes secret projections, please see + https://k8s.io/docs/concepts/configuration/secret/#projection-of-secret-keys-to-specific-paths + NOTE: If CustomTLSSecret is provided, CustomReplicationClientTLSSecret + MUST be provided and the ca.crt provided must be the same. + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the Secret or its + key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + dataSource: + description: Specifies a data source for bootstrapping the PostgreSQL + cluster. + properties: + pgbackrest: + description: |- + Defines a pgBackRest cloud-based data source that can be used to pre-populate the + PostgreSQL data directory for a new PostgreSQL cluster using a pgBackRest restore. + The PGBackRest field is incompatible with the PostgresCluster field: only one + data source can be used for pre-populating a new PostgreSQL cluster + properties: + affinity: + description: |- + Scheduling constraints of the pgBackRest restore Job. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + properties: + nodeAffinity: + description: Describes node affinity scheduling rules + for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching + the corresponding nodeSelectorTerm, in the + range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector + terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. + co-locate this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules + (e.g. avoid putting this pod in the same node, zone, + etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + configuration: + description: |- + Projected volumes containing custom pgBackRest configuration. These files are mounted + under "/etc/pgbackrest/conf.d" alongside any pgBackRest configuration generated by the + PostgreSQL Operator: + https://pgbackrest.org/configuration.html + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root + to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path. + Must be utf-8 encoded. The first item of + the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the + Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about + the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + type: array + global: + additionalProperties: + type: string + description: |- + Global pgBackRest configuration settings. These settings are included in the "global" + section of the pgBackRest configuration generated by the PostgreSQL Operator, and then + mounted under "/etc/pgbackrest/conf.d": + https://pgbackrest.org/configuration.html + type: object + options: + description: |- + Command line options to include when running the pgBackRest restore command. + https://pgbackrest.org/command.html#command-restore + items: + type: string + type: array + priorityClassName: + description: |- + Priority class name for the pgBackRest restore Job pod. Changing this + value causes PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + type: string + repo: + description: Defines a pgBackRest repository + properties: + azure: + description: Represents a pgBackRest repository that is + created using Azure storage + properties: + container: + description: The Azure container utilized for the + repository + type: string + required: + - container + type: object + gcs: + description: Represents a pgBackRest repository that is + created using Google Cloud Storage + properties: + bucket: + description: The GCS bucket utilized for the repository + type: string + required: + - bucket + type: object + name: + description: The name of the repository + pattern: ^repo[1-4] + type: string + s3: + description: |- + RepoS3 represents a pgBackRest repository that is created using AWS S3 (or S3-compatible) + storage + properties: + bucket: + description: The S3 bucket utilized for the repository + type: string + endpoint: + description: A valid endpoint corresponding to the + specified region + type: string + region: + description: The region corresponding to the S3 bucket + type: string + required: + - bucket + - endpoint + - region + type: object + schedules: + description: |- + Defines the schedules for the pgBackRest backups + Full, Differential and Incremental backup types are supported: + https://pgbackrest.org/user-guide.html#concept/backup + properties: + differential: + description: |- + Defines the Cron schedule for a differential pgBackRest backup. + Follows the standard Cron schedule syntax: + https://k8s.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax + minLength: 6 + type: string + full: + description: |- + Defines the Cron schedule for a full pgBackRest backup. + Follows the standard Cron schedule syntax: + https://k8s.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax + minLength: 6 + type: string + incremental: + description: |- + Defines the Cron schedule for an incremental pgBackRest backup. + Follows the standard Cron schedule syntax: + https://k8s.io/docs/concepts/workloads/controllers/cron-jobs/#cron-schedule-syntax + minLength: 6 + type: string + type: object + volume: + description: Represents a pgBackRest repository that is + created using a PersistentVolumeClaim + properties: + volumeClaimSpec: + description: Defines a PersistentVolumeClaim spec + used to create and/or bind a volume + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource + being referenced + type: string + name: + description: Name is the name of resource + being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource + being referenced + type: string + name: + description: Name is the name of resource + being referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over volumes + to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference + to the PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + required: + - volumeClaimSpec + type: object + required: + - name + type: object + resources: + description: Resource requirements for the pgBackRest restore + Job. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + stanza: + default: db + description: |- + The name of an existing pgBackRest stanza to use as the data source for the new PostgresCluster. + Defaults to `db` if not provided. + type: string + tolerations: + description: |- + Tolerations of the pgBackRest restore Job. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + volumes: + description: Volumes to add to Restore Job Pods + properties: + additional: + description: Additional pre-existing volumes to add to + the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + required: + - repo + - stanza + type: object + x-kubernetes-validations: + - fieldPath: .repo + message: Only S3, GCS or Azure repos can be used as a pgBackRest + data source. + rule: '!has(self.repo.volume)' + postgresCluster: + description: |- + Defines a pgBackRest data source that can be used to pre-populate the PostgreSQL data + directory for a new PostgreSQL cluster using a pgBackRest restore. + The PGBackRest field is incompatible with the PostgresCluster field: only one + data source can be used for pre-populating a new PostgreSQL cluster + properties: + affinity: + description: |- + Scheduling constraints of the pgBackRest restore Job. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + properties: + nodeAffinity: + description: Describes node affinity scheduling rules + for the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching + the corresponding nodeSelectorTerm, in the + range 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector + terms. The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. + co-locate this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules + (e.g. avoid putting this pod in the same node, zone, + etc. as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, + associated with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label + key that the selector applies + to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + clusterName: + description: |- + The name of an existing PostgresCluster to use as the data source for the new PostgresCluster. + Defaults to the name of the PostgresCluster being created if not provided. + type: string + clusterNamespace: + description: |- + The namespace of the cluster specified as the data source using the clusterName field. + Defaults to the namespace of the PostgresCluster being created if not provided. + type: string + options: + description: |- + Command line options to include when running the pgBackRest restore command. + https://pgbackrest.org/command.html#command-restore + items: + type: string + type: array + priorityClassName: + description: |- + Priority class name for the pgBackRest restore Job pod. Changing this + value causes PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + type: string + repoName: + description: |- + The name of the pgBackRest repo within the source PostgresCluster that contains the backups + that should be utilized to perform a pgBackRest restore when initializing the data source + for the new PostgresCluster. + pattern: ^repo[1-4] + type: string + resources: + description: Resource requirements for the pgBackRest restore + Job. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + tolerations: + description: |- + Tolerations of the pgBackRest restore Job. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + volumes: + description: Volumes to add to Restore Job Pods + properties: + additional: + description: Additional pre-existing volumes to add to + the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object + required: + - repoName + type: object + volumes: + description: Defines any existing volumes to reuse for this PostgresCluster. + properties: + pgBackRestVolume: + description: |- + Defines the existing pgBackRest repo volume and directory to use in the + current PostgresCluster. + properties: + directory: + description: |- + The existing directory. When not set, a move Job is not created for the + associated volume. + type: string + pvcName: + description: The existing PVC name. + type: string + required: + - pvcName + type: object + pgDataVolume: + description: |- + Defines the existing pgData volume and directory to use in the current + PostgresCluster. + properties: + directory: + description: |- + The existing directory. When not set, a move Job is not created for the + associated volume. + type: string + pvcName: + description: The existing PVC name. + type: string + required: + - pvcName + type: object + pgWALVolume: + description: |- + Defines the existing pg_wal volume and directory to use in the current + PostgresCluster. Note that a defined pg_wal volume MUST be accompanied by + a pgData volume. + properties: + directory: + description: |- + The existing directory. When not set, a move Job is not created for the + associated volume. + type: string + pvcName: + description: The existing PVC name. + type: string + required: + - pvcName + type: object + type: object + type: object + databaseInitSQL: + description: |- + DatabaseInitSQL defines a ConfigMap containing custom SQL that will + be run after the cluster is initialized. This ConfigMap must be in the same + namespace as the cluster. + properties: + key: + description: Key is the ConfigMap data key that points to a SQL + string + type: string + name: + description: Name is the name of a ConfigMap + type: string + required: + - key + - name + type: object + disableDefaultPodScheduling: + description: |- + Whether or not the PostgreSQL cluster should use the defined default + scheduling constraints. If the field is unset or false, the default + scheduling constraints will be used in addition to any custom constraints + provided. + type: boolean + image: + description: |- + The image name to use for PostgreSQL containers. When omitted, the value + comes from an operator environment variable. For standard PostgreSQL images, + the format is RELATED_IMAGE_POSTGRES_{postgresVersion}, + e.g. RELATED_IMAGE_POSTGRES_13. For PostGIS enabled PostgreSQL images, + the format is RELATED_IMAGE_POSTGRES_{postgresVersion}_GIS_{postGISVersion}, + e.g. RELATED_IMAGE_POSTGRES_13_GIS_3.1. + type: string + imagePullPolicy: + description: |- + ImagePullPolicy is used to determine when Kubernetes will attempt to + pull (download) container images. + More info: https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy + enum: + - Always + - Never + - IfNotPresent + maxLength: 15 + type: string + imagePullSecrets: + description: |- + The image pull secrets used to pull from a private registry + Changing this value causes all running pods to restart. + https://k8s.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + items: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: array + instances: + description: |- + Specifies one or more sets of PostgreSQL pods that replicate data for + this cluster. + items: + properties: + affinity: + description: |- + Scheduling constraints of a PostgreSQL pod. Changing this value causes + PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + properties: + nodeAffinity: + description: Describes node affinity scheduling rules for + the pod. + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node matches the corresponding matchExpressions; the + node(s) with the highest sum are the most preferred. + items: + description: |- + An empty preferred scheduling term matches all objects with implicit weight 0 + (i.e. it's a no-op). A null preferred scheduling term matches no objects (i.e. is also a no-op). + properties: + preference: + description: A node selector term, associated + with the corresponding weight. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + weight: + description: Weight associated with matching the + corresponding nodeSelectorTerm, in the range + 1-100. + format: int32 + type: integer + required: + - preference + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to an update), the system + may or may not try to eventually evict the pod from its node. + properties: + nodeSelectorTerms: + description: Required. A list of node selector terms. + The terms are ORed. + items: + description: |- + A null or empty node selector term matches no objects. The requirements of + them are ANDed. + The TopologySelectorTerm type implements a subset of the NodeSelectorTerm. + properties: + matchExpressions: + description: A list of node selector requirements + by node's labels. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchFields: + description: A list of node selector requirements + by node's fields. + items: + description: |- + A node selector requirement is a selector that contains values, a key, and an operator + that relates the key and values. + properties: + key: + description: The label key that the + selector applies to. + type: string + operator: + description: |- + Represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists, DoesNotExist. Gt, and Lt. + type: string + values: + description: |- + An array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. If the operator is Gt or Lt, the values + array must have a single element, which will be interpreted as an integer. + This array is replaced during a strategic merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + type: object + x-kubernetes-map-type: atomic + type: array + x-kubernetes-list-type: atomic + required: + - nodeSelectorTerms + type: object + x-kubernetes-map-type: atomic + type: object + podAffinity: + description: Describes pod affinity scheduling rules (e.g. + co-locate this pod in the same node, zone, etc. as some + other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + podAntiAffinity: + description: Describes pod anti-affinity scheduling rules + (e.g. avoid putting this pod in the same node, zone, etc. + as some other pod(s)). + properties: + preferredDuringSchedulingIgnoredDuringExecution: + description: |- + The scheduler will prefer to schedule pods to nodes that satisfy + the anti-affinity expressions specified by this field, but it may choose + a node that violates one or more of the expressions. The node that is + most preferred is the one with the greatest sum of weights, i.e. + for each node that meets all of the scheduling requirements (resource + request, requiredDuringScheduling anti-affinity expressions, etc.), + compute a sum by iterating through the elements of this field and adding + "weight" to the sum if the node has pods which matches the corresponding podAffinityTerm; the + node(s) with the highest sum are the most preferred. + items: + description: The weights of all of the matched WeightedPodAffinityTerm + fields are added per-node to find the most preferred + node(s) + properties: + podAffinityTerm: + description: Required. A pod affinity term, associated + with the corresponding weight. + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list + of label selector requirements. The + requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key + that the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + weight: + description: |- + weight associated with matching the corresponding podAffinityTerm, + in the range 1-100. + format: int32 + type: integer + required: + - podAffinityTerm + - weight + type: object + type: array + x-kubernetes-list-type: atomic + requiredDuringSchedulingIgnoredDuringExecution: + description: |- + If the anti-affinity requirements specified by this field are not met at + scheduling time, the pod will not be scheduled onto the node. + If the anti-affinity requirements specified by this field cease to be met + at some point during pod execution (e.g. due to a pod label update), the + system may or may not try to eventually evict the pod from its node. + When there are multiple elements, the lists of nodes corresponding to each + podAffinityTerm are intersected, i.e. all terms must be satisfied. + items: + description: |- + Defines a set of pods (namely those matching the labelSelector + relative to the given namespace(s)) that this pod should be + co-located (affinity) or not co-located (anti-affinity) with, + where co-located is defined as running on a node whose value of + the label with key matches that of any node on which + a pod of the set of pods is running + properties: + labelSelector: + description: |- + A label query over a set of resources, in this case pods. + If it's null, this PodAffinityTerm matches with no Pods. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key in (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both matchLabelKeys and labelSelector. + Also, matchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + mismatchLabelKeys: + description: |- + MismatchLabelKeys is a set of pod label keys to select which pods will + be taken into consideration. The keys are used to lookup values from the + incoming pod labels, those key-value labels are merged with `labelSelector` as `key notin (value)` + to select the group of existing pods which pods will be taken into consideration + for the incoming pod's pod (anti) affinity. Keys that don't exist in the incoming + pod labels will be ignored. The default value is empty. + The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. + Also, mismatchLabelKeys cannot be set when labelSelector isn't set. + items: + type: string + type: array + x-kubernetes-list-type: atomic + namespaceSelector: + description: |- + A label query over the set of namespaces that the term applies to. + The term is applied to the union of the namespaces selected by this field + and the ones listed in the namespaces field. + null selector and null or empty namespaces list means "this pod's namespace". + An empty selector ({}) matches all namespaces. + properties: + matchExpressions: + description: matchExpressions is a list of + label selector requirements. The requirements + are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + namespaces: + description: |- + namespaces specifies a static list of namespace names that the term applies to. + The term is applied to the union of the namespaces listed in this field + and the ones selected by namespaceSelector. + null or empty namespaces list and null namespaceSelector means "this pod's namespace". + items: + type: string + type: array + x-kubernetes-list-type: atomic + topologyKey: + description: |- + This pod should be co-located (affinity) or not co-located (anti-affinity) with the pods matching + the labelSelector in the specified namespaces, where co-located is defined as running on a node + whose value of the label with key topologyKey matches that of any node on which any of the + selected pods is running. + Empty topologyKey is not allowed. + type: string + required: + - topologyKey + type: object + type: array + x-kubernetes-list-type: atomic + type: object + type: object + containers: + description: |- + Custom sidecars for PostgreSQL instance pods. Changing this value causes + PostgreSQL to restart. + items: + description: A single application container that you want + to run within a pod. + properties: + args: + description: |- + Arguments to the entrypoint. + The container image's CMD is used if this is not provided. + Variable references $(VAR_NAME) are expanded using the container's environment. If a variable + cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will + produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless + of whether the variable exists or not. Cannot be updated. + More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + items: + type: string + type: array + x-kubernetes-list-type: atomic + command: + description: |- + Entrypoint array. Not executed within a shell. + The container image's ENTRYPOINT is used if this is not provided. + Variable references $(VAR_NAME) are expanded using the container's environment. If a variable + cannot be resolved, the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. "$$(VAR_NAME)" will + produce the string literal "$(VAR_NAME)". Escaped references will never be expanded, regardless + of whether the variable exists or not. Cannot be updated. + More info: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#running-a-command-in-a-shell + items: + type: string + type: array + x-kubernetes-list-type: atomic + env: + description: |- + List of environment variables to set in the container. + Cannot be updated. + items: + description: EnvVar represents an environment variable + present in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's + value. Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for + volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the + pod's namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret + or its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + envFrom: + description: |- + List of sources to populate environment variables in the container. + The keys defined within a source must be a C_IDENTIFIER. All invalid keys + will be reported as an event when the container is starting. When a key exists in multiple + sources, the value associated with the last source will take precedence. + Values defined by an Env with a duplicate key will take precedence. + Cannot be updated. + items: + description: EnvFromSource represents the source of + a set of ConfigMaps or Secrets + properties: + configMapRef: + description: The ConfigMap to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + prefix: + description: Optional text to prepend to the name + of each environment variable. Must be a C_IDENTIFIER. + type: string + secretRef: + description: The Secret to select from + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret must + be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + type: object + type: array + x-kubernetes-list-type: atomic + image: + description: |- + Container image name. + More info: https://kubernetes.io/docs/concepts/containers/images + This field is optional to allow higher level config management to default or override + container images in workload controllers like Deployments and StatefulSets. + type: string + imagePullPolicy: + description: |- + Image pull policy. + One of Always, Never, IfNotPresent. + Defaults to Always if :latest tag is specified, or IfNotPresent otherwise. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/containers/images#updating-images + type: string + lifecycle: + description: |- + Actions that the management system should take in response to container lifecycle events. + Cannot be updated. + properties: + postStart: + description: |- + PostStart is called immediately after a container is created. If the handler fails, + the container is terminated and restarted according to its restart policy. + Other management of the container blocks until the hook completes. + More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks + properties: + exec: + description: Exec specifies a command to execute + in the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the + request. HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom + header to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + sleep: + description: Sleep represents a duration that + the container should sleep. + properties: + seconds: + description: Seconds is the number of seconds + to sleep. + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + description: |- + Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. + properties: + host: + description: 'Optional: Host name to connect + to, defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + preStop: + description: |- + PreStop is called immediately before a container is terminated due to an + API request or management event such as liveness/startup probe failure, + preemption, resource contention, etc. The handler is not called if the + container crashes or exits. The Pod's termination grace period countdown begins before the + PreStop hook is executed. Regardless of the outcome of the handler, the + container will eventually terminate within the Pod's termination grace + period (unless delayed by finalizers). Other management of the container blocks until the hook completes + or until the termination grace period is reached. + More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks + properties: + exec: + description: Exec specifies a command to execute + in the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the + request. HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom + header to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + sleep: + description: Sleep represents a duration that + the container should sleep. + properties: + seconds: + description: Seconds is the number of seconds + to sleep. + format: int64 + type: integer + required: + - seconds + type: object + tcpSocket: + description: |- + Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. + properties: + host: + description: 'Optional: Host name to connect + to, defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + type: object + stopSignal: + description: |- + StopSignal defines which signal will be sent to a container when it is being stopped. + If not specified, the default is defined by the container runtime in use. + StopSignal can only be set for Pods with a non-empty .spec.os.name + type: string + type: object + livenessProbe: + description: |- + Periodic probe of container liveness. + Container will be restarted if the probe fails. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + properties: + exec: + description: Exec specifies a command to execute in + the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + description: |- + Minimum consecutive failures for the probe to be considered failed after having succeeded. + Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. + Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + default: "" + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest + (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. + HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom header + to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: |- + Number of seconds after the container has started before liveness probes are initiated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + periodSeconds: + description: |- + How often (in seconds) to perform the probe. + Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: |- + Minimum consecutive successes for the probe to be considered successful after having failed. + Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to a + TCP port. + properties: + host: + description: 'Optional: Host name to connect to, + defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: |- + Optional duration in seconds the pod needs to terminate gracefully upon probe failure. + The grace period is the duration in seconds after the processes running in the pod are sent + a termination signal and the time when the processes are forcibly halted with a kill signal. + Set this value longer than the expected cleanup time for your process. + If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this + value overrides the value provided by the pod spec. + Value must be non-negative integer. The value zero indicates stop immediately via + the kill signal (no opportunity to shut down). + This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: |- + Number of seconds after which the probe times out. + Defaults to 1 second. Minimum value is 1. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + type: object + name: + description: |- + Name of the container specified as a DNS_LABEL. + Each container in a pod must have a unique name (DNS_LABEL). + Cannot be updated. + type: string + ports: + description: |- + List of ports to expose from the container. Not specifying a port here + DOES NOT prevent that port from being exposed. Any port which is + listening on the default "0.0.0.0" address inside a container will be + accessible from the network. + Modifying this array with strategic merge patch may corrupt the data. + For more information See https://github.com/kubernetes/kubernetes/issues/108255. + Cannot be updated. + items: + description: ContainerPort represents a network port + in a single container. + properties: + containerPort: + description: |- + Number of port to expose on the pod's IP address. + This must be a valid port number, 0 < x < 65536. + format: int32 + type: integer + hostIP: + description: What host IP to bind the external port + to. + type: string + hostPort: + description: |- + Number of port to expose on the host. + If specified, this must be a valid port number, 0 < x < 65536. + If HostNetwork is specified, this must match ContainerPort. + Most containers do not need this. + format: int32 + type: integer + name: + description: |- + If specified, this must be an IANA_SVC_NAME and unique within the pod. Each + named port in a pod must have a unique name. Name for the port that can be + referred to by services. + type: string + protocol: + default: TCP + description: |- + Protocol for port. Must be UDP, TCP, or SCTP. + Defaults to "TCP". + type: string + required: + - containerPort + type: object + type: array + x-kubernetes-list-map-keys: + - containerPort + - protocol + x-kubernetes-list-type: map + readinessProbe: + description: |- + Periodic probe of container service readiness. + Container will be removed from service endpoints if the probe fails. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + properties: + exec: + description: Exec specifies a command to execute in + the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + description: |- + Minimum consecutive failures for the probe to be considered failed after having succeeded. + Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. + Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + default: "" + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest + (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. + HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom header + to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: |- + Number of seconds after the container has started before liveness probes are initiated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + periodSeconds: + description: |- + How often (in seconds) to perform the probe. + Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: |- + Minimum consecutive successes for the probe to be considered successful after having failed. + Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to a + TCP port. + properties: + host: + description: 'Optional: Host name to connect to, + defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: |- + Optional duration in seconds the pod needs to terminate gracefully upon probe failure. + The grace period is the duration in seconds after the processes running in the pod are sent + a termination signal and the time when the processes are forcibly halted with a kill signal. + Set this value longer than the expected cleanup time for your process. + If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this + value overrides the value provided by the pod spec. + Value must be non-negative integer. The value zero indicates stop immediately via + the kill signal (no opportunity to shut down). + This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: |- + Number of seconds after which the probe times out. + Defaults to 1 second. Minimum value is 1. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + type: object + resizePolicy: + description: Resources resize policy for the container. + items: + description: ContainerResizePolicy represents resource + resize policy for the container. + properties: + resourceName: + description: |- + Name of the resource to which this resource resize policy applies. + Supported values: cpu, memory. + type: string + restartPolicy: + description: |- + Restart policy to apply when specified resource is resized. + If not specified, it defaults to NotRequired. + type: string + required: + - resourceName + - restartPolicy + type: object + type: array + x-kubernetes-list-type: atomic + resources: + description: |- + Compute Resources required by this container. + Cannot be updated. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + restartPolicy: + description: |- + RestartPolicy defines the restart behavior of individual containers in a pod. + This field may only be set for init containers, and the only allowed value is "Always". + For non-init containers or when this field is not specified, + the restart behavior is defined by the Pod's restart policy and the container type. + Setting the RestartPolicy as "Always" for the init container will have the following effect: + this init container will be continually restarted on + exit until all regular containers have terminated. Once all regular + containers have completed, all init containers with restartPolicy "Always" + will be shut down. This lifecycle differs from normal init containers and + is often referred to as a "sidecar" container. Although this init + container still starts in the init container sequence, it does not wait + for the container to complete before proceeding to the next init + container. Instead, the next init container starts immediately after this + init container is started, or after any startupProbe has successfully + completed. + type: string + securityContext: + description: |- + SecurityContext defines the security options the container should be run with. + If set, the fields of SecurityContext override the equivalent fields of PodSecurityContext. + More info: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ + properties: + allowPrivilegeEscalation: + description: |- + AllowPrivilegeEscalation controls whether a process can gain more + privileges than its parent process. This bool directly controls if + the no_new_privs flag will be set on the container process. + AllowPrivilegeEscalation is true always when the container is: + 1) run as Privileged + 2) has CAP_SYS_ADMIN + Note that this field cannot be set when spec.os.name is windows. + type: boolean + appArmorProfile: + description: |- + appArmorProfile is the AppArmor options to use by this container. If set, this profile + overrides the pod's appArmorProfile. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile loaded on the node that should be used. + The profile must be preconfigured on the node to work. + Must match the loaded name of the profile. + Must be set if and only if type is "Localhost". + type: string + type: + description: |- + type indicates which kind of AppArmor profile will be applied. + Valid options are: + Localhost - a profile pre-loaded on the node. + RuntimeDefault - the container runtime's default profile. + Unconfined - no AppArmor enforcement. + type: string + required: + - type + type: object + capabilities: + description: |- + The capabilities to add/drop when running containers. + Defaults to the default set of capabilities granted by the container runtime. + Note that this field cannot be set when spec.os.name is windows. + properties: + add: + description: Added capabilities + items: + description: Capability represent POSIX capabilities + type + type: string + type: array + x-kubernetes-list-type: atomic + drop: + description: Removed capabilities + items: + description: Capability represent POSIX capabilities + type + type: string + type: array + x-kubernetes-list-type: atomic + type: object + privileged: + description: |- + Run container in privileged mode. + Processes in privileged containers are essentially equivalent to root on the host. + Defaults to false. + Note that this field cannot be set when spec.os.name is windows. + type: boolean + procMount: + description: |- + procMount denotes the type of proc mount to use for the containers. + The default value is Default which uses the container runtime defaults for + readonly paths and masked paths. + This requires the ProcMountType feature flag to be enabled. + Note that this field cannot be set when spec.os.name is windows. + type: string + readOnlyRootFilesystem: + description: |- + Whether this container has a read-only root filesystem. + Default is false. + Note that this field cannot be set when spec.os.name is windows. + type: boolean + runAsGroup: + description: |- + The GID to run the entrypoint of the container process. + Uses runtime default if unset. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + runAsNonRoot: + description: |- + Indicates that the container must run as a non-root user. + If true, the Kubelet will validate the image at runtime to ensure that it + does not run as UID 0 (root) and fail to start the container if it does. + If unset or false, no such validation will be performed. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + type: boolean + runAsUser: + description: |- + The UID to run the entrypoint of the container process. + Defaults to user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + format: int64 + type: integer + seLinuxOptions: + description: |- + The SELinux context to be applied to the container. + If unspecified, the container runtime will allocate a random SELinux context for each + container. May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is windows. + properties: + level: + description: Level is SELinux level label that + applies to the container. + type: string + role: + description: Role is a SELinux role label that + applies to the container. + type: string + type: + description: Type is a SELinux type label that + applies to the container. + type: string + user: + description: User is a SELinux user label that + applies to the container. + type: string + type: object + seccompProfile: + description: |- + The seccomp options to use by this container. If seccomp options are + provided at both the pod & container level, the container options + override the pod options. + Note that this field cannot be set when spec.os.name is windows. + properties: + localhostProfile: + description: |- + localhostProfile indicates a profile defined in a file on the node should be used. + The profile must be preconfigured on the node to work. + Must be a descending path, relative to the kubelet's configured seccomp profile location. + Must be set if type is "Localhost". Must NOT be set for any other type. + type: string + type: + description: |- + type indicates which kind of seccomp profile will be applied. + Valid options are: + + Localhost - a profile defined in a file on the node should be used. + RuntimeDefault - the container runtime default profile should be used. + Unconfined - no profile should be applied. + type: string + required: + - type + type: object + windowsOptions: + description: |- + The Windows specific settings applied to all containers. + If unspecified, the options from the PodSecurityContext will be used. + If set in both SecurityContext and PodSecurityContext, the value specified in SecurityContext takes precedence. + Note that this field cannot be set when spec.os.name is linux. + properties: + gmsaCredentialSpec: + description: |- + GMSACredentialSpec is where the GMSA admission webhook + (https://github.com/kubernetes-sigs/windows-gmsa) inlines the contents of the + GMSA credential spec named by the GMSACredentialSpecName field. + type: string + gmsaCredentialSpecName: + description: GMSACredentialSpecName is the name + of the GMSA credential spec to use. + type: string + hostProcess: + description: |- + HostProcess determines if a container should be run as a 'Host Process' container. + All of a Pod's containers must have the same effective HostProcess value + (it is not allowed to have a mix of HostProcess containers and non-HostProcess containers). + In addition, if HostProcess is true then HostNetwork must also be set to true. + type: boolean + runAsUserName: + description: |- + The UserName in Windows to run the entrypoint of the container process. + Defaults to the user specified in image metadata if unspecified. + May also be set in PodSecurityContext. If set in both SecurityContext and + PodSecurityContext, the value specified in SecurityContext takes precedence. + type: string + type: object + type: object + startupProbe: + description: |- + StartupProbe indicates that the Pod has successfully initialized. + If specified, no other probes are executed until this completes successfully. + If this probe fails, the Pod will be restarted, just as if the livenessProbe failed. + This can be used to provide different probe parameters at the beginning of a Pod's lifecycle, + when it might take a long time to load data or warm a cache, than during steady-state operation. + This cannot be updated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + properties: + exec: + description: Exec specifies a command to execute in + the container. + properties: + command: + description: |- + Command is the command line to execute inside the container, the working directory for the + command is root ('/') in the container's filesystem. The command is simply exec'd, it is + not run inside a shell, so traditional shell instructions ('|', etc) won't work. To use + a shell, you need to explicitly call out to that shell. + Exit status of 0 is treated as live/healthy and non-zero is unhealthy. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + failureThreshold: + description: |- + Minimum consecutive failures for the probe to be considered failed after having succeeded. + Defaults to 3. Minimum value is 1. + format: int32 + type: integer + grpc: + description: GRPC specifies a GRPC HealthCheckRequest. + properties: + port: + description: Port number of the gRPC service. + Number must be in the range 1 to 65535. + format: int32 + type: integer + service: + default: "" + description: |- + Service is the name of the service to place in the gRPC HealthCheckRequest + (see https://github.com/grpc/grpc/blob/master/doc/health-checking.md). + + If this is not specified, the default behavior is defined by gRPC. + type: string + required: + - port + type: object + httpGet: + description: HTTPGet specifies an HTTP GET request + to perform. + properties: + host: + description: |- + Host name to connect to, defaults to the pod IP. You probably want to set + "Host" in httpHeaders instead. + type: string + httpHeaders: + description: Custom headers to set in the request. + HTTP allows repeated headers. + items: + description: HTTPHeader describes a custom header + to be used in HTTP probes + properties: + name: + description: |- + The header field name. + This will be canonicalized upon output, so case-variant names will be understood as the same header. + type: string + value: + description: The header field value + type: string + required: + - name + - value + type: object + type: array + x-kubernetes-list-type: atomic + path: + description: Path to access on the HTTP server. + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Name or number of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + scheme: + description: |- + Scheme to use for connecting to the host. + Defaults to HTTP. + type: string + required: + - port + type: object + initialDelaySeconds: + description: |- + Number of seconds after the container has started before liveness probes are initiated. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + periodSeconds: + description: |- + How often (in seconds) to perform the probe. + Default to 10 seconds. Minimum value is 1. + format: int32 + type: integer + successThreshold: + description: |- + Minimum consecutive successes for the probe to be considered successful after having failed. + Defaults to 1. Must be 1 for liveness and startup. Minimum value is 1. + format: int32 + type: integer + tcpSocket: + description: TCPSocket specifies a connection to a + TCP port. + properties: + host: + description: 'Optional: Host name to connect to, + defaults to the pod IP.' + type: string + port: + anyOf: + - type: integer + - type: string + description: |- + Number or name of the port to access on the container. + Number must be in the range 1 to 65535. + Name must be an IANA_SVC_NAME. + x-kubernetes-int-or-string: true + required: + - port + type: object + terminationGracePeriodSeconds: + description: |- + Optional duration in seconds the pod needs to terminate gracefully upon probe failure. + The grace period is the duration in seconds after the processes running in the pod are sent + a termination signal and the time when the processes are forcibly halted with a kill signal. + Set this value longer than the expected cleanup time for your process. + If this value is nil, the pod's terminationGracePeriodSeconds will be used. Otherwise, this + value overrides the value provided by the pod spec. + Value must be non-negative integer. The value zero indicates stop immediately via + the kill signal (no opportunity to shut down). + This is a beta field and requires enabling ProbeTerminationGracePeriod feature gate. + Minimum value is 1. spec.terminationGracePeriodSeconds is used if unset. + format: int64 + type: integer + timeoutSeconds: + description: |- + Number of seconds after which the probe times out. + Defaults to 1 second. Minimum value is 1. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes + format: int32 + type: integer + type: object + stdin: + description: |- + Whether this container should allocate a buffer for stdin in the container runtime. If this + is not set, reads from stdin in the container will always result in EOF. + Default is false. + type: boolean + stdinOnce: + description: |- + Whether the container runtime should close the stdin channel after it has been opened by + a single attach. When stdin is true the stdin stream will remain open across multiple attach + sessions. If stdinOnce is set to true, stdin is opened on container start, is empty until the + first client attaches to stdin, and then remains open and accepts data until the client disconnects, + at which time stdin is closed and remains closed until the container is restarted. If this + flag is false, a container processes that reads from stdin will never receive an EOF. + Default is false + type: boolean + terminationMessagePath: + description: |- + Optional: Path at which the file to which the container's termination message + will be written is mounted into the container's filesystem. + Message written is intended to be brief final status, such as an assertion failure message. + Will be truncated by the node if greater than 4096 bytes. The total message length across + all containers will be limited to 12kb. + Defaults to /dev/termination-log. + Cannot be updated. + type: string + terminationMessagePolicy: + description: |- + Indicate how the termination message should be populated. File will use the contents of + terminationMessagePath to populate the container status message on both success and failure. + FallbackToLogsOnError will use the last chunk of container log output if the termination + message file is empty and the container exited with an error. + The log output is limited to 2048 bytes or 80 lines, whichever is smaller. + Defaults to File. + Cannot be updated. + type: string + tty: + description: |- + Whether this container should allocate a TTY for itself, also requires 'stdin' to be true. + Default is false. + type: boolean + volumeDevices: + description: volumeDevices is the list of block devices + to be used by the container. + items: + description: volumeDevice describes a mapping of a raw + block device within a container. + properties: + devicePath: + description: devicePath is the path inside of the + container that the device will be mapped to. + type: string + name: + description: name must match the name of a persistentVolumeClaim + in the pod + type: string + required: + - devicePath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - devicePath + x-kubernetes-list-type: map + volumeMounts: + description: |- + Pod volumes to mount into the container's filesystem. + Cannot be updated. + items: + description: VolumeMount describes a mounting of a Volume + within a container. + properties: + mountPath: + description: |- + Path within the container at which the volume should be mounted. Must + not contain ':'. + type: string + mountPropagation: + description: |- + mountPropagation determines how mounts are propagated from the host + to container and the other way around. + When not set, MountPropagationNone is used. + This field is beta in 1.10. + When RecursiveReadOnly is set to IfPossible or to Enabled, MountPropagation must be None or unspecified + (which defaults to None). + type: string + name: + description: This must match the Name of a Volume. + type: string + readOnly: + description: |- + Mounted read-only if true, read-write otherwise (false or unspecified). + Defaults to false. + type: boolean + recursiveReadOnly: + description: |- + RecursiveReadOnly specifies whether read-only mounts should be handled + recursively. + + If ReadOnly is false, this field has no meaning and must be unspecified. + + If ReadOnly is true, and this field is set to Disabled, the mount is not made + recursively read-only. If this field is set to IfPossible, the mount is made + recursively read-only, if it is supported by the container runtime. If this + field is set to Enabled, the mount is made recursively read-only if it is + supported by the container runtime, otherwise the pod will not be started and + an error will be generated to indicate the reason. + + If this field is set to IfPossible or Enabled, MountPropagation must be set to + None (or be unspecified, which defaults to None). + + If this field is not specified, it is treated as an equivalent of Disabled. + type: string + subPath: + description: |- + Path within the volume from which the container's volume should be mounted. + Defaults to "" (volume's root). + type: string + subPathExpr: + description: |- + Expanded path within the volume from which the container's volume should be mounted. + Behaves similarly to SubPath but environment variable references $(VAR_NAME) are expanded using the container's environment. + Defaults to "" (volume's root). + SubPathExpr and SubPath are mutually exclusive. + type: string + required: + - mountPath + - name + type: object + type: array + x-kubernetes-list-map-keys: + - mountPath + x-kubernetes-list-type: map + workingDir: + description: |- + Container's working directory. + If not specified, the container runtime's default will be used, which + might be configured in the container image. + Cannot be updated. + type: string + required: + - name + type: object + type: array + dataVolumeClaimSpec: + description: |- + Defines a PersistentVolumeClaim for PostgreSQL data. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over volumes to consider + for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to the + PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + metadata: + description: Metadata contains metadata for custom resources + properties: + annotations: + additionalProperties: + type: string + type: object + labels: + additionalProperties: + type: string + type: object + type: object + minAvailable: + anyOf: + - type: integer + - type: string + description: |- + Minimum number of pods that should be available at a time. + Defaults to one when the replicas field is greater than one. + x-kubernetes-int-or-string: true + name: + default: "" + description: |- + Name that associates this set of PostgreSQL pods. This field is optional + when only one instance set is defined. Each instance set in a cluster + must have a unique name. The combined length of this and the cluster name + must be 46 characters or less. + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?)?$ + type: string + priorityClassName: + description: |- + Priority class name for the PostgreSQL pod. Changing this value causes + PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + type: string + replicas: + default: 1 + description: Number of desired PostgreSQL pods. + format: int32 + minimum: 1 + type: integer + resources: + description: Compute resources of a PostgreSQL container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + sidecars: + description: Configuration for instance sidecar containers + properties: + replicaCertCopy: + description: Defines the configuration for the replica cert + copy sidecar container + properties: + resources: + description: Resource requirements for a sidecar container + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry + in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. + type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object + type: object + tablespaceVolumes: + description: |- + The list of tablespaces volumes to mount for this postgrescluster + This field requires enabling TablespaceVolumes feature gate + items: + properties: + dataVolumeClaimSpec: + description: |- + Defines a PersistentVolumeClaim for a tablespace. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being + referenced + type: string + name: + description: Name is the name of resource being + referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being + referenced + type: string + name: + description: Name is the name of resource being + referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over volumes + to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to + the PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + name: + description: |- + The name for the tablespace, used as the path name for the volume. + Must be unique in the instance set since they become the directory names. + minLength: 1 + pattern: ^[a-z][a-z0-9]*$ + type: string + required: + - dataVolumeClaimSpec + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + tolerations: + description: |- + Tolerations of a PostgreSQL pod. Changing this value causes PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + items: + description: |- + The pod this Toleration is attached to tolerates any taint that matches + the triple using the matching operator . + properties: + effect: + description: |- + Effect indicates the taint effect to match. Empty means match all taint effects. + When specified, allowed values are NoSchedule, PreferNoSchedule and NoExecute. + type: string + key: + description: |- + Key is the taint key that the toleration applies to. Empty means match all taint keys. + If the key is empty, operator must be Exists; this combination means to match all values and all keys. + type: string + operator: + description: |- + Operator represents a key's relationship to the value. + Valid operators are Exists and Equal. Defaults to Equal. + Exists is equivalent to wildcard for value, so that a pod can + tolerate all taints of a particular category. + type: string + tolerationSeconds: + description: |- + TolerationSeconds represents the period of time the toleration (which must be + of effect NoExecute, otherwise this field is ignored) tolerates the taint. By default, + it is not set, which means tolerate the taint forever (do not evict). Zero and + negative values will be treated as 0 (evict immediately) by the system. + format: int64 + type: integer + value: + description: |- + Value is the taint value the toleration matches to. + If the operator is Exists, the value should be empty, otherwise just a regular string. + type: string + type: object + type: array + topologySpreadConstraints: + description: |- + Topology spread constraints of a PostgreSQL pod. Changing this value causes + PostgreSQL to restart. + More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/ + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array + volumes: + properties: + additional: + description: Additional pre-existing volumes to add to the + pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + temp: + description: |- + An ephemeral volume for temporary files. + More info: https://kubernetes.io/docs/concepts/storage/ephemeral-volumes + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being + referenced + type: string + name: + description: Name is the name of resource being + referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being + referenced + type: string + name: + description: Name is the name of resource being + referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over volumes + to consider for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the + selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to + the PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + type: object + walVolumeClaimSpec: + description: |- + Defines a separate PersistentVolumeClaim for PostgreSQL's write-ahead log. + More info: https://www.postgresql.org/docs/current/wal.html + properties: + accessModes: + description: |- + accessModes contains the desired access modes the volume should have. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#access-modes-1 + items: + type: string + type: array + x-kubernetes-list-type: atomic + dataSource: + description: |- + dataSource field can be used to specify either: + * An existing VolumeSnapshot object (snapshot.storage.k8s.io/VolumeSnapshot) + * An existing PVC (PersistentVolumeClaim) + If the provisioner or an external controller can support the specified data source, + it will create a new volume based on the contents of the specified data source. + When the AnyVolumeDataSource feature gate is enabled, dataSource contents will be copied to dataSourceRef, + and dataSourceRef contents will be copied to dataSource when dataSourceRef.namespace is not specified. + If the namespace is specified, then dataSourceRef will not be copied to dataSource. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + x-kubernetes-map-type: atomic + dataSourceRef: + description: |- + dataSourceRef specifies the object from which to populate the volume with data, if a non-empty + volume is desired. This may be any object from a non-empty API group (non + core object) or a PersistentVolumeClaim object. + When this field is specified, volume binding will only succeed if the type of + the specified object matches some installed volume populator or dynamic + provisioner. + This field will replace the functionality of the dataSource field and as such + if both fields are non-empty, they must have the same value. For backwards + compatibility, when namespace isn't specified in dataSourceRef, + both fields (dataSource and dataSourceRef) will be set to the same + value automatically if one of them is empty and the other is non-empty. + When namespace is specified in dataSourceRef, + dataSource isn't set to the same value and must be empty. + There are three important differences between dataSource and dataSourceRef: + * While dataSource only allows two specific types of objects, dataSourceRef + allows any non-core object, as well as PersistentVolumeClaim objects. + * While dataSource ignores disallowed values (dropping them), dataSourceRef + preserves all values, and generates an error if a disallowed value is + specified. + * While dataSource only allows local objects, dataSourceRef allows objects + in any namespaces. + (Beta) Using this field requires the AnyVolumeDataSource feature gate to be enabled. + (Alpha) Using the namespace field of dataSourceRef requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + properties: + apiGroup: + description: |- + APIGroup is the group for the resource being referenced. + If APIGroup is not specified, the specified Kind must be in the core API group. + For any other third-party types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + namespace: + description: |- + Namespace is the namespace of resource being referenced + Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. + (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + type: string + required: + - kind + - name + type: object + resources: + description: |- + resources represents the minimum resources the volume should have. + If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements + that are lower than previous value but must still be higher than capacity recorded in the + status field of the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources + properties: + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + selector: + description: selector is a label query over volumes to consider + for binding. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + storageClassName: + description: |- + storageClassName is the name of the StorageClass required by the claim. + More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 + type: string + volumeAttributesClassName: + description: |- + volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. + If specified, the CSI driver will create or update the volume with the attributes defined + in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, + it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass + will be applied to the claim but it's not allowed to reset this field to empty string once it is set. + If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass + will be set by the persistentvolume controller if it exists. + If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be + set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource + exists. + More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). + type: string + volumeMode: + description: |- + volumeMode defines what type of volume is required by the claim. + Value of Filesystem is implied when not included in claim spec. + type: string + volumeName: + description: volumeName is the binding reference to the + PersistentVolume backing this claim. + type: string + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) + required: + - dataVolumeClaimSpec + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + instrumentation: + description: |- + Configuration for the OpenTelemetry collector container used to collect + logs and metrics. + properties: + config: + description: Config is the place for users to configure exporters + and provide files. + properties: + detectors: + description: |- + Resource detectors add identifying attributes to logs and metrics. These run in the order they are defined. + More info: https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/resourcedetectionprocessor#readme + items: + properties: + attributes: + additionalProperties: + type: boolean + description: |- + Attributes to use from this detector. Detectors usually add every attribute + they know automatically. Names omitted here behave according to detector defaults. + maxProperties: 30 + minProperties: 1 + type: object + x-kubernetes-map-type: atomic + name: + description: 'Name of the resource detector to enable: + `aks`, `eks`, `gcp`, etc.' + maxLength: 20 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + environmentVariables: + description: |- + EnvironmentVariables allows the user to add environment variables to the + collector container. + items: + description: EnvVar represents an environment variable present + in a Container. + properties: + name: + description: Name of the environment variable. Must + be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. + Cannot be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the ConfigMap or + its key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath + is written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in + the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of + the exposed resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's + namespace + properties: + key: + description: The key of the secret to select + from. Must be a valid secret key. + type: string + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: Specify whether the Secret or its + key must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + x-kubernetes-validations: + - message: Cannot overwrite environment variables set by + operator + rule: self.name != 'K8S_POD_NAMESPACE' && self.name != + 'K8S_POD_NAME' && self.name != 'PGPASSWORD' + minItems: 1 + type: array + x-kubernetes-list-type: atomic + exporters: + description: |- + Exporters allows users to configure OpenTelemetry exporters that exist + in the collector image. + type: object + x-kubernetes-preserve-unknown-fields: true + files: + description: |- + Files allows the user to mount projected volumes into the collector + Pod so that files can be referenced by the collector as needed. + items: + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. + properties: + clusterTrustBundle: + description: |- + ClusterTrustBundle allows a pod to access the `.spec.trustBundle` field + of ClusterTrustBundle objects in an auto-updating file. + + Alpha, gated by the ClusterTrustBundleProjection feature gate. + + ClusterTrustBundle objects can either be selected by name, or by the + combination of signer name and a label selector. + + Kubelet performs aggressive normalization of the PEM contents written + into the pod filesystem. Esoteric PEM features such as inter-block + comments and block headers are stripped. Certificates are deduplicated. + The ordering of certificates within the file is arbitrary, and Kubelet + may change the order over time. + properties: + labelSelector: + description: |- + Select all ClusterTrustBundles that match this label selector. Only has + effect if signerName is set. Mutually-exclusive with name. If unset, + interpreted as "match nothing". If set but empty, interpreted as "match + everything". + properties: + matchExpressions: + description: matchExpressions is a list of label + selector requirements. The requirements are + ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that + the selector applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + Select a single ClusterTrustBundle by object name. Mutually-exclusive + with signerName and labelSelector. + type: string + optional: + description: |- + If true, don't block pod startup if the referenced ClusterTrustBundle(s) + aren't available. If using name, then the named ClusterTrustBundle is + allowed not to exist. If using signerName, then the combination of + signerName and labelSelector is allowed to match zero + ClusterTrustBundles. + type: boolean + path: + description: Relative path from the volume root + to write the bundle. + type: string + signerName: + description: |- + Select all ClusterTrustBundles that match this signer name. + Mutually-exclusive with name. The contents of all selected + ClusterTrustBundles will be unified and deduplicated. + type: string + required: + - path + type: object + configMap: + description: configMap information about the configMap + data to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + ConfigMap will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the ConfigMap, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional specify whether the ConfigMap + or its keys must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + downwardAPI: + description: downwardAPI information about the downwardAPI + data to project + properties: + items: + description: Items is a list of DownwardAPIVolume + file + items: + description: DownwardAPIVolumeFile represents + information to create the file containing the + pod field + properties: + fieldRef: + description: 'Required: Selects a field of + the pod: only annotations, labels, name, + namespace and uid are supported.' + properties: + apiVersion: + description: Version of the schema the + FieldPath is written in terms of, defaults + to "v1". + type: string + fieldPath: + description: Path of the field to select + in the specified API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + mode: + description: |- + Optional: mode bits used to set permissions on this file, must be an octal value + between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: 'Required: Path is the relative + path name of the file to be created. Must + not be absolute or contain the ''..'' path. + Must be utf-8 encoded. The first item of + the relative path must not start with ''..''' + type: string + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, requests.cpu and requests.memory) are currently supported. + properties: + containerName: + description: 'Container name: required + for volumes, optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format + of the exposed resources, defaults to + "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + required: + - path + type: object + type: array + x-kubernetes-list-type: atomic + type: object + secret: + description: secret information about the secret data + to project + properties: + items: + description: |- + items if unspecified, each key-value pair in the Data field of the referenced + Secret will be projected into the volume as a file whose name is the + key and content is the value. If specified, the listed keys will be + projected into the specified paths, and unlisted keys will not be + present. If a key is specified which is not present in the Secret, + the volume setup will error unless it is marked optional. Paths must be + relative and may not contain the '..' path or start with '..'. + items: + description: Maps a string key to a path within + a volume. + properties: + key: + description: key is the key to project. + type: string + mode: + description: |- + mode is Optional: mode bits used to set permissions on this file. + Must be an octal value between 0000 and 0777 or a decimal value between 0 and 511. + YAML accepts both octal and decimal values, JSON requires decimal values for mode bits. + If not specified, the volume defaultMode will be used. + This might be in conflict with other options that affect the file + mode, like fsGroup, and the result can be other mode bits set. + format: int32 + type: integer + path: + description: |- + path is the relative path of the file to map the key to. + May not be an absolute path. + May not contain the path element '..'. + May not start with the string '..'. + type: string + required: + - key + - path + type: object + type: array + x-kubernetes-list-type: atomic + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + optional: + description: optional field specify whether the + Secret or its key must be defined + type: boolean + type: object + x-kubernetes-map-type: atomic + serviceAccountToken: + description: serviceAccountToken is information about + the serviceAccountToken data to project + properties: + audience: + description: |- + audience is the intended audience of the token. A recipient of a token + must identify itself with an identifier specified in the audience of the + token, and otherwise should reject the token. The audience defaults to the + identifier of the apiserver. + type: string + expirationSeconds: + description: |- + expirationSeconds is the requested duration of validity of the service + account token. As the token approaches expiration, the kubelet volume + plugin will proactively rotate the service account token. The kubelet will + start trying to rotate the token if the token is older than 80 percent of + its time to live or if the token is older than 24 hours.Defaults to 1 hour + and must be at least 10 minutes. + format: int64 + type: integer + path: + description: |- + path is the path relative to the mount point of the file to project the + token into. + type: string + required: + - path + type: object + type: object + minItems: 1 + type: array + x-kubernetes-list-type: atomic + type: object + image: + description: |- + Image name to use for collector containers. When omitted, the value + comes from an operator environment variable. + type: string + logs: + description: Logs is the place for users to configure the log + collection. + properties: + batches: + description: Log records are exported in small batches. Set + this field to change their size and frequency. + properties: + maxDelay: + default: 200ms + description: |- + Maximum time to wait before exporting a log record. Higher numbers + allow more records to be deduplicated and compressed before export. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^((PT)?( *[0-9]+ *(?i:(ms|s|m)|(milli|sec|min)s?))+|0)$ + type: string + x-kubernetes-validations: + - rule: duration("0") <= self && self <= duration("5m") + maxRecords: + description: |- + Maximum number of records to include in an exported batch. When present, + batches this size are sent without any further delay. + format: int32 + minimum: 1 + type: integer + minRecords: + default: 8192 + description: |- + Number of records to wait for before exporting a batch. Higher numbers + allow more records to be deduplicated and compressed before export. + format: int32 + minimum: 0 + type: integer + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: to disable batching, both minRecords and maxDelay + must be zero + rule: (has(self.minRecords) && self.minRecords == 0) == + (has(self.maxDelay) && self.maxDelay == duration('0')) + - message: minRecords cannot be larger than maxRecords + rule: '!has(self.maxRecords) || self.minRecords <= self.maxRecords' + exporters: + description: The names of exporters that should send logs. + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + retentionPeriod: + description: |- + How long to retain log files locally. An RFC 3339 duration or a number + and unit: `12 hr`, `3d`, `4 weeks`, etc. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^(PT)?( *[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$ + type: string + x-kubernetes-validations: + - message: must be at least one hour + rule: duration("1h") <= self && self <= duration("8760h") + type: object + metrics: + description: Metrics is the place for users to configure metrics + collection. + properties: + customQueries: + description: |- + Where users can turn off built-in metrics and also provide their own + custom queries. + properties: + add: + description: User defined queries and metrics. + items: + properties: + collectionInterval: + default: 5s + description: How often the queries should be run. + format: duration + maxLength: 20 + minLength: 1 + pattern: ^((PT)?( *[0-9]+ *(?i:(ms|s|m)|(milli|sec|min)s?))+|0)$ + type: string + x-kubernetes-validations: + - rule: duration("0") <= self && self <= duration("60m") + databases: + description: |- + The databases to target with added custom queries. + Default behavior is to target `postgres`. + items: + type: string + type: array + name: + description: |- + The name of this batch of queries, which will be used in naming the OTel + SqlQuery receiver. + maxLength: 20 + pattern: ^[^\pZ\pC\pS]+$ + type: string + queries: + description: A ConfigMap holding the yaml file that + contains the queries. + properties: + key: + description: Name of the data field within the + ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") + name: + description: Name of the ConfigMap. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + x-kubernetes-map-type: atomic + required: + - name + - queries + type: object + type: array + remove: + description: |- + A list of built-in queries that should be removed. If all queries for a + given SQL statement are removed, the SQL statement will no longer be run. + items: + type: string + type: array + type: object + exporters: + description: The names of exporters that should send metrics. + items: + type: string + minItems: 1 + type: array + x-kubernetes-list-type: set + perDBMetricTargets: + description: User defined databases to target for default + per-db metrics + items: + type: string + type: array + type: object + resources: + description: Resources holds the resource requirements for the + collector container. + properties: + claims: + description: |- + Claims lists the names of resources, defined in spec.resourceClaims, + that are used by this container. + + This is an alpha field and requires enabling the + DynamicResourceAllocation feature gate. + + This field is immutable. It can only be set for containers. + items: + description: ResourceClaim references one entry in PodSpec.ResourceClaims. + properties: + name: + description: |- + Name must match the name of one entry in pod.spec.resourceClaims of + the Pod where this field is used. It makes that resource available + inside a container. type: string - namespace: + request: description: |- - Namespace is the namespace of resource being referenced - Note that when a namespace is specified, a gateway.networking.k8s.io/ReferenceGrant object is required in the referent namespace to allow that namespace's owner to accept the reference. See the ReferenceGrant documentation for details. - (Alpha) This field requires the CrossNamespaceVolumeDataSource feature gate to be enabled. + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. type: string required: - - kind - name type: object - resources: - description: |- - resources represents the minimum resources the volume should have. - If RecoverVolumeExpansionFailure feature is enabled users are allowed to specify resource requirements - that are lower than previous value but must still be higher than capacity recorded in the - status field of the claim. - More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#resources - properties: - limits: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Limits describes the maximum amount of compute resources allowed. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - requests: - additionalProperties: - anyOf: - - type: integer - - type: string - pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ - x-kubernetes-int-or-string: true - description: |- - Requests describes the minimum amount of compute resources required. - If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, - otherwise to an implementation-defined value. Requests cannot exceed Limits. - More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ - type: object - type: object - selector: - description: selector is a label query over volumes to consider - for binding. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - description: |- - A label selector requirement is a selector that contains values, a key, and an operator that - relates the key and values. - properties: - key: - description: key is the label key that the selector - applies to. - type: string - operator: - description: |- - operator represents a key's relationship to a set of values. - Valid operators are In, NotIn, Exists and DoesNotExist. - type: string - values: - description: |- - values is an array of string values. If the operator is In or NotIn, - the values array must be non-empty. If the operator is Exists or DoesNotExist, - the values array must be empty. This array is replaced during a strategic - merge patch. - items: - type: string - type: array - x-kubernetes-list-type: atomic - required: - - key - - operator - type: object - type: array - x-kubernetes-list-type: atomic - matchLabels: - additionalProperties: - type: string - description: |- - matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels - map is equivalent to an element of matchExpressions, whose key field is "key", the - operator is "In", and the values array contains only "value". The requirements are ANDed. - type: object - type: object - x-kubernetes-map-type: atomic - storageClassName: - description: |- - storageClassName is the name of the StorageClass required by the claim. - More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes#class-1 - type: string - volumeAttributesClassName: - description: |- - volumeAttributesClassName may be used to set the VolumeAttributesClass used by this claim. - If specified, the CSI driver will create or update the volume with the attributes defined - in the corresponding VolumeAttributesClass. This has a different purpose than storageClassName, - it can be changed after the claim is created. An empty string value means that no VolumeAttributesClass - will be applied to the claim but it's not allowed to reset this field to empty string once it is set. - If unspecified and the PersistentVolumeClaim is unbound, the default VolumeAttributesClass - will be set by the persistentvolume controller if it exists. - If the resource referred to by volumeAttributesClass does not exist, this PersistentVolumeClaim will be - set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource - exists. - More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Alpha) Using this field requires the VolumeAttributesClass feature gate to be enabled. - type: string - volumeMode: - description: |- - volumeMode defines what type of volume is required by the claim. - Value of Filesystem is implied when not included in claim spec. - type: string - volumeName: - description: volumeName is the binding reference to the - PersistentVolume backing this claim. - type: string - type: object - x-kubernetes-validations: - - message: missing accessModes - rule: has(self.accessModes) && size(self.accessModes) > 0 - - message: missing storage request - rule: has(self.resources) && has(self.resources.requests) - && has(self.resources.requests.storage) - required: - - dataVolumeClaimSpec - type: object - minItems: 1 - type: array - x-kubernetes-list-map-keys: - - name - x-kubernetes-list-type: map + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + limits: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Limits describes the maximum amount of compute resources allowed. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + requests: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Requests describes the minimum amount of compute resources required. + If Requests is omitted for a container, it defaults to Limits if that is explicitly specified, + otherwise to an implementation-defined value. Requests cannot exceed Limits. + More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ + type: object + type: object + type: object metadata: description: Metadata contains metadata for custom resources properties: @@ -11089,8 +31432,9 @@ spec: https://github.com/prometheus-community/postgres_exporter#flags Changing the values of field causes PostgreSQL and the exporter to restart. items: - description: Projection that may be projected along - with other supported volume types + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. properties: clusterTrustBundle: description: |- @@ -11500,6 +31844,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -11560,6 +31910,36 @@ spec: format: int32 minimum: 3 type: integer + logging: + description: Patroni log configuration settings. + properties: + level: + default: INFO + description: |- + The Patroni log level. + More info: https://docs.python.org/3/library/logging.html#levels + enum: + - CRITICAL + - ERROR + - WARNING + - INFO + - DEBUG + - NOTSET + maxLength: 10 + type: string + storageLimit: + anyOf: + - type: integer + - type: string + description: |- + Limits the total amount of space taken by Patroni log files. + Minimum value is 25MB. + More info: https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/quantity + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + required: + - storageLimit + type: object port: default: 8008 description: |- @@ -11593,6 +31973,7 @@ spec: enum: - Switchover - Failover + maxLength: 15 type: string required: - enabled @@ -11926,7 +32307,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -11941,7 +32321,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -12109,7 +32488,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -12124,7 +32502,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -12290,7 +32667,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -12305,7 +32681,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -12473,7 +32848,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -12488,7 +32862,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -12597,8 +32970,9 @@ spec: reloaded. More info: https://www.pgbouncer.org/config.html#include-directive items: - description: Projection that may be projected along - with other supported volume types + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. properties: clusterTrustBundle: description: |- @@ -13109,7 +33483,7 @@ spec: Cannot be updated. items: description: EnvFromSource represents the source of - a set of ConfigMaps + a set of ConfigMaps or Secrets properties: configMapRef: description: The ConfigMap to select from @@ -13130,8 +33504,8 @@ spec: type: object x-kubernetes-map-type: atomic prefix: - description: An optional identifier to prepend - to each key in the ConfigMap. Must be a C_IDENTIFIER. + description: Optional text to prepend to the name + of each environment variable. Must be a C_IDENTIFIER. type: string secretRef: description: The Secret to select from @@ -13182,7 +33556,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -13197,7 +33572,7 @@ spec: x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -13248,8 +33623,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that + the container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -13262,8 +33637,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -13295,7 +33670,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -13310,7 +33686,7 @@ spec: x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -13361,8 +33737,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that + the container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -13375,8 +33751,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -13395,6 +33771,12 @@ spec: - port type: object type: object + stopSignal: + description: |- + StopSignal defines which signal will be sent to a container when it is being stopped. + If not specified, the default is defined by the container runtime in use. + StopSignal can only be set for Pods with a non-empty .spec.os.name + type: string type: object livenessProbe: description: |- @@ -13404,7 +33786,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -13425,8 +33808,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving - a GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -13445,7 +33827,7 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -13513,7 +33895,7 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving + description: TCPSocket specifies a connection to a TCP port. properties: host: @@ -13619,7 +34001,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -13640,8 +34023,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving - a GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -13660,7 +34042,7 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -13728,7 +34110,7 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving + description: TCPSocket specifies a connection to a TCP port. properties: host: @@ -13816,6 +34198,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -13939,7 +34327,7 @@ spec: procMount: description: |- procMount denotes the type of proc mount to use for the containers. - The default is DefaultProcMount which uses the container runtime defaults for + The default value is Default which uses the container runtime defaults for readonly paths and masked paths. This requires the ProcMountType feature flag to be enabled. Note that this field cannot be set when spec.os.name is windows. @@ -14072,7 +34460,8 @@ spec: More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle#container-probes properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -14093,8 +34482,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving - a GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -14113,7 +34501,7 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -14181,7 +34569,7 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving + description: TCPSocket specifies a connection to a TCP port. properties: host: @@ -14499,6 +34887,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -14539,12 +34933,31 @@ spec: enum: - Cluster - Local + maxLength: 10 type: string internalTrafficPolicy: description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' enum: - Cluster - Local + maxLength: 10 + type: string + ipFamilies: + items: + description: |- + IPFamily represents the IP Family (IPv4 or IPv6). This type is used + to express the family of an IP expressed by a type (e.g. service.spec.ipFamilies). + enum: + - IPv4 + - IPv6 + type: string + type: array + ipFamilyPolicy: + description: 'More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/' + enum: + - SingleStack + - PreferDualStack + - RequireDualStack type: string metadata: description: Metadata contains metadata for custom resources @@ -14573,6 +34986,7 @@ spec: - ClusterIP - NodePort - LoadBalancer + maxLength: 15 type: string type: object sidecars: @@ -14604,6 +35018,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -14806,7 +35226,6 @@ spec: - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. If this value is nil, the behavior is equivalent to the Honor policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. type: string nodeTaintsPolicy: description: |- @@ -14817,7 +35236,6 @@ spec: - Ignore: node taints are ignored. All nodes are included. If this value is nil, the behavior is equivalent to the Ignore policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. type: string topologyKey: description: |- @@ -14859,6 +35277,58 @@ spec: - whenUnsatisfiable type: object type: array + volumes: + description: PGBouncerVolumesSpec defines the configuration + for pgBouncer additional volumes + properties: + additional: + description: Additional pre-existing volumes to add to + the pod. + items: + properties: + claimName: + description: Name of an existing PersistentVolumeClaim. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + containers: + description: |- + The names of containers in which to mount this volume. + The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + items: + maxLength: 63 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + maxItems: 10 + type: array + x-kubernetes-list-type: set + name: + allOf: + - maxLength: 63 + - maxLength: 55 + description: |- + The name of the directory in which to mount this volume. + Volumes are mounted in containers at `/volumes/{name}`. + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string + readOnly: + description: When true, mount the volume read-only, + otherwise read-write. Defaults to false. + type: boolean + required: + - claimName + - name + type: object + x-kubernetes-map-type: atomic + maxItems: 10 + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + type: object type: object required: - pgBouncer @@ -14872,12 +35342,31 @@ spec: enum: - Cluster - Local + maxLength: 10 type: string internalTrafficPolicy: description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' enum: - Cluster - Local + maxLength: 10 + type: string + ipFamilies: + items: + description: |- + IPFamily represents the IP Family (IPv4 or IPv6). This type is used + to express the family of an IP expressed by a type (e.g. service.spec.ipFamilies). + enum: + - IPv4 + - IPv6 + type: string + type: array + ipFamilyPolicy: + description: 'More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/' + enum: + - SingleStack + - PreferDualStack + - RequireDualStack type: string metadata: description: Metadata contains metadata for custom resources @@ -14906,6 +35395,7 @@ spec: - ClusterIP - NodePort - LoadBalancer + maxLength: 15 type: string type: object service: @@ -14917,12 +35407,31 @@ spec: enum: - Cluster - Local + maxLength: 10 type: string internalTrafficPolicy: description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' enum: - Cluster - Local + maxLength: 10 + type: string + ipFamilies: + items: + description: |- + IPFamily represents the IP Family (IPv4 or IPv6). This type is used + to express the family of an IP expressed by a type (e.g. service.spec.ipFamilies). + enum: + - IPv4 + - IPv6 + type: string + type: array + ipFamilyPolicy: + description: 'More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/' + enum: + - SingleStack + - PreferDualStack + - RequireDualStack type: string metadata: description: Metadata contains metadata for custom resources @@ -14951,6 +35460,7 @@ spec: - ClusterIP - NodePort - LoadBalancer + maxLength: 15 type: string type: object shutdown: @@ -15296,7 +35806,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -15311,7 +35820,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -15479,7 +35987,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -15494,7 +36001,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -15660,7 +36166,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -15675,7 +36180,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -15843,7 +36347,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both matchLabelKeys and labelSelector. Also, matchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -15858,7 +36361,6 @@ spec: pod labels will be ignored. The default value is empty. The same key is forbidden to exist in both mismatchLabelKeys and labelSelector. Also, mismatchLabelKeys cannot be set when labelSelector isn't set. - This is an alpha field and requires enabling MatchLabelKeysInPodAffinity feature gate. items: type: string type: array @@ -15951,8 +36453,9 @@ spec: Files allows the user to mount projected volumes into the pgAdmin container so that files can be referenced by pgAdmin as needed. items: - description: Projection that may be projected along - with other supported volume types + description: |- + Projection that may be projected along with other supported volume types. + Exactly one of these fields must be set. properties: clusterTrustBundle: description: |- @@ -16280,24 +36783,27 @@ spec: More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html properties: key: - description: The key of the secret to select from. Must - be a valid secret key. + description: Name of the data field within the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ type: string + x-kubernetes-validations: + - message: cannot be "." or start with ".." + rule: self != "." && !self.startsWith("..") name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + description: Name of the Secret. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ type: string optional: - description: Specify whether the Secret or its key - must be defined + description: Whether or not the Secret or its data + must be defined. Defaults to false. type: boolean required: - key + - name type: object x-kubernetes-map-type: atomic settings: @@ -16495,7 +37001,7 @@ spec: set to a Pending state, as reflected by the modifyVolumeStatus field, until such as a resource exists. More info: https://kubernetes.io/docs/concepts/storage/volume-attributes-classes/ - (Alpha) Using this field requires the VolumeAttributesClass feature gate to be enabled. + (Beta) Using this field requires the VolumeAttributesClass feature gate to be enabled (off by default). type: string volumeMode: description: |- @@ -16507,6 +37013,12 @@ spec: PersistentVolume backing this claim. type: string type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: missing accessModes + rule: 0 < size(self.accessModes) + - message: missing storage request + rule: has(self.resources.requests.storage) image: description: |- Name of a container image that can run pgAdmin 4. Changing this value causes @@ -16563,6 +37075,12 @@ spec: the Pod where this field is used. It makes that resource available inside a container. type: string + request: + description: |- + Request is the name chosen for a request in the referenced claim. + If empty, everything from the claim is made available, otherwise + only the result of this request. + type: string required: - name type: object @@ -16603,12 +37121,31 @@ spec: enum: - Cluster - Local + maxLength: 10 type: string internalTrafficPolicy: description: 'More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies' enum: - Cluster - Local + maxLength: 10 + type: string + ipFamilies: + items: + description: |- + IPFamily represents the IP Family (IPv4 or IPv6). This type is used + to express the family of an IP expressed by a type (e.g. service.spec.ipFamilies). + enum: + - IPv4 + - IPv6 + type: string + type: array + ipFamilyPolicy: + description: 'More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/' + enum: + - SingleStack + - PreferDualStack + - RequireDualStack type: string metadata: description: Metadata contains metadata for custom resources @@ -16637,6 +37174,7 @@ spec: - ClusterIP - NodePort - LoadBalancer + maxLength: 15 type: string type: object tolerations: @@ -16806,7 +37344,6 @@ spec: - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. If this value is nil, the behavior is equivalent to the Honor policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. type: string nodeTaintsPolicy: description: |- @@ -16817,7 +37354,6 @@ spec: - Ignore: node taints are ignored. All nodes are included. If this value is nil, the behavior is equivalent to the Ignore policy. - This is a beta-level feature default enabled by the NodeInclusionPolicyInPodTopologySpread feature flag. type: string topologyKey: description: |- @@ -16879,9 +37415,6 @@ spec: database from this list does NOT revoke access. This field is ignored for the "postgres" user. items: - description: |- - PostgreSQL identifiers are limited in length but may contain any character. - More info: https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS maxLength: 63 minLength: 1 type: string @@ -16921,6 +37454,7 @@ spec: enum: - ASCII - AlphaNumeric + maxLength: 15 type: string required: - type diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 2eb849e138..fc86b653e1 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -23,27 +23,27 @@ spec: - name: CRUNCHY_DEBUG value: "true" - name: RELATED_IMAGE_POSTGRES_16 - value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-16.4-2" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-16.9-2520" - name: RELATED_IMAGE_POSTGRES_16_GIS_3.3 - value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-16.4-3.3-2" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-16.9-3.3-2520" - name: RELATED_IMAGE_POSTGRES_16_GIS_3.4 - value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-16.4-3.4-2" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-16.9-3.4-2520" - name: RELATED_IMAGE_POSTGRES_17 - value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-17.0-0" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-17.5-2520" - name: RELATED_IMAGE_POSTGRES_17_GIS_3.4 - value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi8-17.0-3.4-0" - - name: RELATED_IMAGE_PGADMIN - value: "registry.developers.crunchydata.com/crunchydata/crunchy-pgadmin4:ubi8-4.30-31" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres-gis:ubi9-17.5-3.5-2520" - name: RELATED_IMAGE_PGBACKREST - value: "registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi8-2.53.1-0" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi9-2.54.2-2520" - name: RELATED_IMAGE_PGBOUNCER - value: "registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi8-1.23-0" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi9-1.24-2520" - name: RELATED_IMAGE_PGEXPORTER - value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres-exporter:latest" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-postgres-exporter:ubi9-0.17.1-2520" - name: RELATED_IMAGE_PGUPGRADE - value: "registry.developers.crunchydata.com/crunchydata/crunchy-upgrade:latest" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-upgrade:ubi9-17.5-2520" - name: RELATED_IMAGE_STANDALONE_PGADMIN - value: "registry.developers.crunchydata.com/crunchydata/crunchy-pgadmin4:ubi8-8.12-0" + value: "registry.developers.crunchydata.com/crunchydata/crunchy-pgadmin4:ubi9-9.2-2520" + - name: RELATED_IMAGE_COLLECTOR + value: "registry.developers.crunchydata.com/crunchydata/postgres-operator:ubi9-5.8.2-0" securityContext: allowPrivilegeEscalation: false capabilities: { drop: [ALL] } diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d5783d00b1..aa19cdacbf 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -67,6 +67,18 @@ rules: - list - patch - watch +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + verbs: + - create +- apiGroups: + - authorization.k8s.io + resources: + - subjectaccessreviews + verbs: + - create - apiGroups: - batch resources: diff --git a/go.mod b/go.mod index d268d66018..b608e2e0e7 100644 --- a/go.mod +++ b/go.mod @@ -1,96 +1,138 @@ module github.com/crunchydata/postgres-operator -go 1.22.0 +// If this is changing when you don't want it to, see hack/go-get.sh +go 1.24.0 require ( - github.com/go-logr/logr v1.4.2 - github.com/golang-jwt/jwt/v5 v5.2.1 - github.com/google/go-cmp v0.6.0 + github.com/go-logr/logr v1.4.3 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 - github.com/kubernetes-csi/external-snapshotter/client/v8 v8.0.0 - github.com/onsi/ginkgo/v2 v2.17.2 - github.com/onsi/gomega v1.33.1 - github.com/pganalyze/pg_query_go/v5 v5.1.0 + github.com/kubernetes-csi/external-snapshotter/client/v8 v8.2.0 + github.com/onsi/ginkgo/v2 v2.23.4 + github.com/onsi/gomega v1.38.0 + github.com/pganalyze/pg_query_go/v6 v6.1.0 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.9.3 - github.com/xdg-go/stringprep v1.0.2 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 - go.opentelemetry.io/otel v1.27.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.2.0 - go.opentelemetry.io/otel/sdk v1.27.0 - go.opentelemetry.io/otel/trace v1.27.0 - golang.org/x/crypto v0.27.0 - gotest.tools/v3 v3.1.0 - k8s.io/api v0.30.2 - k8s.io/apimachinery v0.30.2 - k8s.io/client-go v0.30.2 - k8s.io/component-base v0.30.2 - sigs.k8s.io/controller-runtime v0.18.4 - sigs.k8s.io/yaml v1.4.0 + github.com/xdg-go/stringprep v1.0.4 + go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 + go.opentelemetry.io/contrib/propagators/autoprop v0.57.0 + go.opentelemetry.io/otel v1.33.0 + go.opentelemetry.io/otel/sdk v1.33.0 + go.opentelemetry.io/otel/trace v1.33.0 + golang.org/x/crypto v0.41.0 + golang.org/x/tools v0.36.0 + gotest.tools/v3 v3.5.2 + k8s.io/api v0.33.3 + k8s.io/apimachinery v0.33.3 + k8s.io/client-go v0.33.3 + k8s.io/component-base v0.33.3 + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff + sigs.k8s.io/controller-runtime v0.21.0 + sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 + sigs.k8s.io/yaml v1.6.0 ) require ( + cel.dev/expr v0.19.1 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // 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.6.0+incompatible // indirect - github.com/evanphx/json-patch/v5 v5.9.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-logr/stdr v1.2.2 // 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/gobuffalo/flect v1.0.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/gnostic-models v0.6.8 // indirect - github.com/google/gofuzz v1.2.0 // indirect - github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect - github.com/gorilla/websocket v1.5.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect - github.com/imdario/mergo v0.3.16 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.23.2 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/moby/spdystream v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/spdystream v0.5.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/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/prometheus/client_golang v1.19.1 // indirect + github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect - github.com/prometheus/common v0.54.0 // indirect + github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect - go.opentelemetry.io/otel/metric v1.27.0 // indirect - go.opentelemetry.io/proto/otlp v1.3.1 // indirect - golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 // indirect - golang.org/x/net v0.29.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/term v0.24.0 // indirect - golang.org/x/text v0.18.0 // indirect - golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.22.0 // indirect + github.com/spf13/cobra v1.9.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect + go.opentelemetry.io/contrib/propagators/aws v1.32.0 // indirect + go.opentelemetry.io/contrib/propagators/b3 v1.32.0 // indirect + go.opentelemetry.io/contrib/propagators/jaeger v1.32.0 // indirect + go.opentelemetry.io/contrib/propagators/ot v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect + go.opentelemetry.io/otel/log v0.8.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.32.0 // indirect + go.opentelemetry.io/proto/otlp v1.4.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/oauth2 v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect - google.golang.org/grpc v1.66.2 // indirect - google.golang.org/protobuf v1.34.2 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/grpc v1.68.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.30.2 // indirect - k8s.io/klog/v2 v2.120.1 // indirect - k8s.io/kube-openapi v0.0.0-20240521193020-835d969ad83a // indirect - k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 // indirect - sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect - sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + k8s.io/apiextensions-apiserver v0.33.0 // indirect + k8s.io/apiserver v0.33.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect + sigs.k8s.io/controller-tools v0.17.3 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.6.0 // indirect ) + +// https://go.dev/doc/modules/managing-dependencies#tools +tool sigs.k8s.io/controller-tools/cmd/controller-gen diff --git a/go.sum b/go.sum index aed2056f6f..c3170dd36e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4= +cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -8,22 +12,28 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= 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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/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/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -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/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= @@ -36,52 +46,63 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr 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/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= -github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -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-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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/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/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.23.2 h1:UdEe3CvQh3Nv+E/j9r1Y//WO0K0cSyD7/y0bzyLIMI4= +github.com/google/cel-go v0.23.2/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= -github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= -github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= -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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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/kubernetes-csi/external-snapshotter/client/v8 v8.0.0 h1:mjQG0Vakr2h246kEDR85U8y8ZhPgT3bguTCajRa/jaw= -github.com/kubernetes-csi/external-snapshotter/client/v8 v8.0.0/go.mod h1:E3vdYxHj2C2q6qo8/Da4g7P+IcwqRZyy3gJBzYybV9Y= +github.com/kubernetes-csi/external-snapshotter/client/v8 v8.2.0 h1:Q3jQ1NkFqv5o+F8dMmHd8SfEmlcwNeo1immFApntEwE= +github.com/kubernetes-csi/external-snapshotter/client/v8 v8.2.0/go.mod h1:E3vdYxHj2C2q6qo8/Da4g7P+IcwqRZyy3gJBzYybV9Y= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= -github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -91,161 +112,255 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/onsi/ginkgo/v2 v2.17.2 h1:7eMhcy3GimbsA3hEnVKdw/PQM9XN9krpKVXsZdph0/g= -github.com/onsi/ginkgo/v2 v2.17.2/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= -github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= -github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= -github.com/pganalyze/pg_query_go/v5 v5.1.0 h1:MlxQqHZnvA3cbRQYyIrjxEjzo560P6MyTgtlaf3pmXg= -github.com/pganalyze/pg_query_go/v5 v5.1.0/go.mod h1:FsglvxidZsVN+Ltw3Ai6nTgPVcK2BPukH3jCDEqc1Ug= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY= +github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o= +github.com/pganalyze/pg_query_go/v6 v6.1.0 h1:jG5ZLhcVgL1FAw4C/0VNQaVmX1SUJx71wBGdtTtBvls= +github.com/pganalyze/pg_query_go/v6 v6.1.0/go.mod h1:nvTHIuoud6e1SfrUaFwHqT0i4b5Nr+1rPWVds3B5+50= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= +github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 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.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8= -github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= +github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= +github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 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.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc= -github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.2.0/go.mod h1:aT17Fk0Z1Nor9e0uisf98LrntPGMnk4frBO9+dkf69I= -go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= -go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0 h1:R9DE4kQ4k+YtfLI2ULwX82VtNQ2J8yZmA7ZIF/D+7Mc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.27.0/go.mod h1:OQFyQVrDlbe+R7xrEyDr/2Wr67Ol0hRUgsfA+V5A95s= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0 h1:QY7/0NeRPKlzusf40ZE4t1VlMKbqSNT7cJRYzWuja0s= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.27.0/go.mod h1:HVkSiDhTM9BoUJU8qE6j2eSWLLXvi1USXjyd2BXT8PY= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.2.0 h1:OiYdrCq1Ctwnovp6EofSPwlp5aGy4LgKNbkg7PtEUw8= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.2.0/go.mod h1:DUFCmFkXr0VtAHl5Zq2JRx24G6ze5CAq8YfdD36RdX8= -go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= -go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= -go.opentelemetry.io/otel/sdk v1.2.0/go.mod h1:jNN8QtpvbsKhgaC6V5lHiejMoKD+V8uadoSafgHPx1U= -go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= -go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= -go.opentelemetry.io/otel/trace v1.2.0/go.mod h1:N5FLswTubnxKxOJHM7XZC074qpeEdLy3CgAVsdMucK0= -go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= -go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= -go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= -go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/contrib/propagators/autoprop v0.57.0 h1:bNPJOdT5154XxzeFmrh8R+PXnV4t3TZEczy8gHEpcpg= +go.opentelemetry.io/contrib/propagators/autoprop v0.57.0/go.mod h1:Tb0j0mK+QatKdCxCKPN7CSzc7kx/q34/KaohJx/N96s= +go.opentelemetry.io/contrib/propagators/aws v1.32.0 h1:NELzr8bW7a7aHVZj5gaep1PfkvoSCGx+1qNGZx/uhhU= +go.opentelemetry.io/contrib/propagators/aws v1.32.0/go.mod h1:XKMrzHNka3eOA+nGEcNKYVL9s77TAhkwQEynYuaRFnQ= +go.opentelemetry.io/contrib/propagators/b3 v1.32.0 h1:MazJBz2Zf6HTN/nK/s3Ru1qme+VhWU5hm83QxEP+dvw= +go.opentelemetry.io/contrib/propagators/b3 v1.32.0/go.mod h1:B0s70QHYPrJwPOwD1o3V/R8vETNOG9N3qZf4LDYvA30= +go.opentelemetry.io/contrib/propagators/jaeger v1.32.0 h1:K/fOyTMD6GELKTIJBaJ9k3ppF2Njt8MeUGBOwfaWXXA= +go.opentelemetry.io/contrib/propagators/jaeger v1.32.0/go.mod h1:ISE6hda//MTWvtngG7p4et3OCngsrTVfl7c6DjN17f8= +go.opentelemetry.io/contrib/propagators/ot v1.32.0 h1:Poy02A4wOZubHyd2hpHPDgZW+rn6EIq0vCwTZJ6Lmu8= +go.opentelemetry.io/contrib/propagators/ot v1.32.0/go.mod h1:cbhaURV+VR3NIMarzDYZU1RDEkXG1fNd1WMP1XCcGkY= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0 h1:rFwzp68QMgtzu9PgP3jm9XaMICI6TsofWWPcBDKwlsU= +go.opentelemetry.io/otel/exporters/prometheus v0.54.0/go.mod h1:QyjcV9qDP6VeK5qPyKETvNjmaaEc7+gqjh4SS0ZYzDU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= +go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= +go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= +go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= +go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= +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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= 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/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= -golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8 h1:LoYXNGAShUG3m/ehNk4iFctuhGX/+R1ZpfJ4/ia80JM= -golang.org/x/exp v0.0.0-20240604190554-fc45aab8b7f8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 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.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= -golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= +golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= -golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 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.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.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.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY= +golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= 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/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3 h1:QW9+G6Fir4VcRXVH8x3LilNAb6cxBGLa6+GM4hRwexE= -google.golang.org/genproto/googleapis/api v0.0.0-20240610135401-a8a62080eff3/go.mod h1:kdrSS/OiLkPrNUpzD4aHgCq2rVuC/YRxok32HXZ4vRE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= -google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= -google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q= +google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 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= -gotest.tools/v3 v3.1.0 h1:rVV8Tcg/8jHUkPUorwjaMTtemIMVXfIPKiOqnhEhakk= -gotest.tools/v3 v3.1.0/go.mod h1:fHy7eyTmJFO5bQbUsEGQ1v4m2J3Jz9eWL54TP2/ZuYQ= -k8s.io/api v0.30.2 h1:+ZhRj+28QT4UOH+BKznu4CBgPWgkXO7XAvMcMl0qKvI= -k8s.io/api v0.30.2/go.mod h1:ULg5g9JvOev2dG0u2hig4Z7tQ2hHIuS+m8MNZ+X6EmI= -k8s.io/apiextensions-apiserver v0.30.2 h1:l7Eue2t6QiLHErfn2vwK4KgF4NeDgjQkCXtEbOocKIE= -k8s.io/apiextensions-apiserver v0.30.2/go.mod h1:lsJFLYyK40iguuinsb3nt+Sj6CmodSI4ACDLep1rgjw= -k8s.io/apimachinery v0.30.2 h1:fEMcnBj6qkzzPGSVsAZtQThU62SmQ4ZymlXRC5yFSCg= -k8s.io/apimachinery v0.30.2/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= -k8s.io/client-go v0.30.2 h1:sBIVJdojUNPDU/jObC+18tXWcTJVcwyqS9diGdWHk50= -k8s.io/client-go v0.30.2/go.mod h1:JglKSWULm9xlJLx4KCkfLLQ7XwtlbflV6uFFSHTMgVs= -k8s.io/component-base v0.30.2 h1:pqGBczYoW1sno8q9ObExUqrYSKhtE5rW3y6gX88GZII= -k8s.io/component-base v0.30.2/go.mod h1:yQLkQDrkK8J6NtP+MGJOws+/PPeEXNpwFixsUI7h/OE= -k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= -k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20240521193020-835d969ad83a h1:zD1uj3Jf+mD4zmA7W+goE5TxDkI7OGJjBNBzq5fJtLA= -k8s.io/kube-openapi v0.0.0-20240521193020-835d969ad83a/go.mod h1:UxDHUPsUwTOOxSU+oXURfFBcAS6JwiRXTYqYwfuGowc= -k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 h1:jgGTlFYnhF1PM1Ax/lAlxUPE+KfCIXHaathvJg1C3ak= -k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.18.4 h1:87+guW1zhvuPLh1PHybKdYFLU0YJp4FhJRmiHvm5BZw= -sigs.k8s.io/controller-runtime v0.18.4/go.mod h1:TVoGrfdpbA9VRFaRnKgk9P5/atA0pMwq+f+msb9M8Sg= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= -sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= -sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/api v0.33.3 h1:SRd5t//hhkI1buzxb288fy2xvjubstenEKL9K51KBI8= +k8s.io/api v0.33.3/go.mod h1:01Y/iLUjNBM3TAvypct7DIj0M0NIZc+PzAHCIo0CYGE= +k8s.io/apiextensions-apiserver v0.33.0 h1:d2qpYL7Mngbsc1taA4IjJPRJ9ilnsXIrndH+r9IimOs= +k8s.io/apiextensions-apiserver v0.33.0/go.mod h1:VeJ8u9dEEN+tbETo+lFkwaaZPg6uFKLGj5vyNEwwSzc= +k8s.io/apimachinery v0.33.3 h1:4ZSrmNa0c/ZpZJhAgRdcsFcZOw1PQU1bALVQ0B3I5LA= +k8s.io/apimachinery v0.33.3/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/apiserver v0.33.0 h1:QqcM6c+qEEjkOODHppFXRiw/cE2zP85704YrQ9YaBbc= +k8s.io/apiserver v0.33.0/go.mod h1:EixYOit0YTxt8zrO2kBU7ixAtxFce9gKGq367nFmqI8= +k8s.io/client-go v0.33.3 h1:M5AfDnKfYmVJif92ngN532gFqakcGi6RvaOF16efrpA= +k8s.io/client-go v0.33.3/go.mod h1:luqKBQggEf3shbxHY4uVENAxrDISLOarxpTKMiUuujg= +k8s.io/component-base v0.33.3 h1:mlAuyJqyPlKZM7FyaoM/LcunZaaY353RXiOd2+B5tGA= +k8s.io/component-base v0.33.3/go.mod h1:ktBVsBzkI3imDuxYXmVxZ2zxJnYTZ4HAsVj9iF09qp4= +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-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= +k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8= +sigs.k8s.io/controller-runtime v0.21.0/go.mod h1:OSg14+F65eWqIu4DceX7k/+QRAbTTvxeQSNSOQpukWM= +sigs.k8s.io/controller-tools v0.17.3 h1:lwFPLicpBKLgIepah+c8ikRBubFW5kOQyT88r3EwfNw= +sigs.k8s.io/controller-tools v0.17.3/go.mod h1:1ii+oXcYZkxcBXzwv3YZBlzjt1fvkrCGjVF73blosJI= +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/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0 h1:IUA9nvMmnKWcj5jl84xn+T5MnlZKThmUW1TdblaLVAc= +sigs.k8s.io/structured-merge-diff/v4 v4.6.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt index 7fc3d63c10..7c662ee243 100644 --- a/hack/boilerplate.go.txt +++ b/hack/boilerplate.go.txt @@ -1,3 +1,3 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/hack/create-kubeconfig.sh b/hack/create-kubeconfig.sh index 3bebcd194e..87aed13291 100755 --- a/hack/create-kubeconfig.sh +++ b/hack/create-kubeconfig.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +# Copyright 2021 - 2025 Crunchy Data Solutions, Inc. # 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 diff --git a/hack/go-get.sh b/hack/go-get.sh new file mode 100755 index 0000000000..9dbfb96640 --- /dev/null +++ b/hack/go-get.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# +# Copyright 2025 Crunchy Data Solutions, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# +# This runs `$GO get` without changing the "go" directive in the "go.mod" file. +# To change that, pass a "go@go{version}" argument. +# +# https://go.dev/doc/toolchain +# +# Usage: $0 help +# Usage: $0 -u golang.org/x/crypto +# Usage: $0 -u golang.org/x/crypto go@go1.99.0 +# + +set -eu +: "${GO:=go}" + +if [[ "$#" -eq 0 ]] || [[ "$1" == 'help' ]] || [[ "$*" == *'--help'* ]] || [[ "$*" == *'--version'* ]] +then + self=$(command -v -- "$0") + content=$(< "${self}") + content="${content%%$'\n\n'*}" + content="#${content#*$'\n#'}" + content="${content//$'$GO'/${GO}}" + exec echo "${content//$'$0'/$0}" +fi + +version=$(${GO} list -m -f 'go@go{{.GoVersion}}') + +for arg in "$@" +do case "${arg}" in go@go*) version="${arg}" ;; *) esac +done + +${GO} get "$@" "${version}" 'toolchain@none' +${GO} mod tidy diff --git a/hack/update-pgmonitor-installer.sh b/hack/update-pgmonitor-installer.sh index 148a4761c9..827614d526 100755 --- a/hack/update-pgmonitor-installer.sh +++ b/hack/update-pgmonitor-installer.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2022 - 2024 Crunchy Data Solutions, Inc. +# Copyright 2022 - 2025 Crunchy Data Solutions, Inc. # 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 diff --git a/internal/bridge/client.go b/internal/bridge/client.go index d5ad8470f7..272bf67b07 100644 --- a/internal/bridge/client.go +++ b/internal/bridge/client.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -71,7 +71,7 @@ type ClusterApiResource struct { Network string `json:"network_id,omitempty"` Parent string `json:"parent_id,omitempty"` Plan string `json:"plan_id,omitempty"` - PostgresVersion intstr.IntOrString `json:"postgres_version_id,omitempty"` + PostgresVersion intstr.IntOrString `json:"postgres_version_id,omitzero"` Provider string `json:"provider_id,omitempty"` Region string `json:"region_id,omitempty"` Replicas []*ClusterApiResource `json:"replicas,omitempty"` @@ -188,7 +188,7 @@ type PostClustersRequestPayload struct { IsHA bool `json:"is_ha,omitempty"` Keychain string `json:"keychain_id,omitempty"` Network string `json:"network_id,omitempty"` - PostgresVersion intstr.IntOrString `json:"postgres_version_id,omitempty"` + PostgresVersion intstr.IntOrString `json:"postgres_version_id,omitzero"` Provider string `json:"provider_id,omitempty"` Region string `json:"region_id,omitempty"` Storage int64 `json:"storage,omitempty"` @@ -198,7 +198,7 @@ type PostClustersRequestPayload struct { // changing its plan, upgrading its major version, or increasing its storage size. type PostClustersUpgradeRequestPayload struct { Plan string `json:"plan_id,omitempty"` - PostgresVersion intstr.IntOrString `json:"postgres_version_id,omitempty"` + PostgresVersion intstr.IntOrString `json:"postgres_version_id,omitzero"` UpgradeStartTime string `json:"starting_from,omitempty"` Storage int64 `json:"storage,omitempty"` } @@ -207,7 +207,7 @@ type PostClustersUpgradeRequestPayload struct { // TODO: Implement the ability to update an upgrade (this isn't currently being used) type PutClustersUpgradeRequestPayload struct { Plan string `json:"plan_id,omitempty"` - PostgresVersion intstr.IntOrString `json:"postgres_version_id,omitempty"` + PostgresVersion intstr.IntOrString `json:"postgres_version_id,omitzero"` UpgradeStartTime string `json:"starting_from,omitempty"` Storage int64 `json:"storage,omitempty"` UseMaintenanceWindow *bool `json:"use_cluster_maintenance_window,omitempty"` @@ -280,7 +280,7 @@ func (c *Client) doWithBackoff( request.Header = headers.Clone() //nolint:bodyclose // This response is returned to the caller. - response, err = c.Client.Do(request) + response, err = c.Do(request) } // An error indicates there was no response from the server, and the @@ -327,7 +327,7 @@ func (c *Client) doWithRetry( // Retry the request when the server responds with "Too many requests". // - https://docs.crunchybridge.com/api-concepts/getting-started/#status-codes // - https://docs.crunchybridge.com/api-concepts/getting-started/#rate-limiting - for err == nil && response.StatusCode == 429 { + for err == nil && response.StatusCode == http.StatusTooManyRequests { seconds, _ := strconv.Atoi(response.Header.Get("Retry-After")) // Only retry when the response indicates how long to wait. @@ -378,11 +378,11 @@ func (c *Client) CreateAuthObject(ctx context.Context, authn AuthObject) (AuthOb } // 401, Unauthorized - case response.StatusCode == 401: + case response.StatusCode == http.StatusUnauthorized: err = fmt.Errorf("%w: %s", errAuthentication, body) default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -409,7 +409,7 @@ func (c *Client) CreateInstallation(ctx context.Context) (Installation, error) { } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -445,7 +445,7 @@ func (c *Client) ListClusters(ctx context.Context, apiKey, teamId string) ([]*Cl } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -486,7 +486,7 @@ func (c *Client) CreateCluster( } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -524,14 +524,14 @@ func (c *Client) DeleteCluster(ctx context.Context, apiKey, id string) (*Cluster // --https://docs.crunchybridge.com/api-concepts/idempotency#delete-semantics // But also, if we can't find it... // Maybe if no ID we return already deleted? - case response.StatusCode == 410: + case response.StatusCode == http.StatusGone: fallthrough - case response.StatusCode == 404: + case response.StatusCode == http.StatusNotFound: deletedAlready = true err = nil default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -565,7 +565,7 @@ func (c *Client) GetCluster(ctx context.Context, apiKey, id string) (*ClusterApi } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -599,7 +599,7 @@ func (c *Client) GetClusterStatus(ctx context.Context, apiKey, id string) (*Clus } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -633,7 +633,7 @@ func (c *Client) GetClusterUpgrade(ctx context.Context, apiKey, id string) (*Clu } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -674,7 +674,7 @@ func (c *Client) UpgradeCluster( } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -709,7 +709,7 @@ func (c *Client) UpgradeClusterHA(ctx context.Context, apiKey, id, action string } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -724,10 +724,7 @@ func (c *Client) UpdateCluster( ) (*ClusterApiResource, error) { result := &ClusterApiResource{} - clusterbyte, err := json.Marshal(clusterRequestPayload) - if err != nil { - return result, err - } + clusterbyte, _ := json.Marshal(clusterRequestPayload) response, err := c.doWithRetry(ctx, "PATCH", "/clusters/"+id, nil, clusterbyte, http.Header{ "Accept": []string{"application/json"}, @@ -750,7 +747,7 @@ func (c *Client) UpdateCluster( } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -780,7 +777,7 @@ func (c *Client) GetClusterRole(ctx context.Context, apiKey, clusterId, roleName } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } @@ -810,7 +807,7 @@ func (c *Client) ListClusterRoles(ctx context.Context, apiKey, id string) ([]*Cl } default: - //nolint:goerr113 // This is intentionally dynamic. + //nolint:err113 // This is intentionally dynamic. err = fmt.Errorf("%v: %s", response.Status, body) } } diff --git a/internal/bridge/client_test.go b/internal/bridge/client_test.go index 28728c701c..f1aa1c8ddd 100644 --- a/internal/bridge/client_test.go +++ b/internal/bridge/client_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -31,8 +31,8 @@ func TestClientBackoff(t *testing.T) { client := NewClient("", "") var total time.Duration - for i := 1; i <= 50 && client.Backoff.Steps > 0; i++ { - step := client.Backoff.Step() + for i := 1; i <= 50 && client.Steps > 0; i++ { + step := client.Step() total += step t.Logf("%02d:%20v%20v", i, step, total) @@ -68,7 +68,7 @@ func TestClientDoWithBackoff(t *testing.T) { // Client with one attempt, i.e. no backoff. client := NewClient(server.URL, "xyz") - client.Backoff.Steps = 1 + client.Steps = 1 assert.Equal(t, client.BaseURL.String(), server.URL) ctx := context.Background() @@ -113,8 +113,8 @@ func TestClientDoWithBackoff(t *testing.T) { // Client with brief backoff. client := NewClient(server.URL, "") - client.Backoff.Duration = time.Millisecond - client.Backoff.Steps = 5 + client.Duration = time.Millisecond + client.Steps = 5 assert.Equal(t, client.BaseURL.String(), server.URL) ctx := context.Background() @@ -170,8 +170,8 @@ func TestClientDoWithBackoff(t *testing.T) { // Client with brief backoff. client := NewClient(server.URL, "") - client.Backoff.Duration = time.Millisecond - client.Backoff.Steps = 5 + client.Duration = time.Millisecond + client.Steps = 5 assert.Equal(t, client.BaseURL.String(), server.URL) ctx := context.Background() @@ -190,8 +190,8 @@ func TestClientDoWithBackoff(t *testing.T) { // Client with lots of brief backoff. client := NewClient(server.URL, "") - client.Backoff.Duration = time.Millisecond - client.Backoff.Steps = 100 + client.Duration = time.Millisecond + client.Steps = 100 assert.Equal(t, client.BaseURL.String(), server.URL) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) diff --git a/internal/bridge/crunchybridgecluster/apply.go b/internal/bridge/crunchybridgecluster/apply.go index d77d719d6a..6edd870790 100644 --- a/internal/bridge/crunchybridgecluster/apply.go +++ b/internal/bridge/crunchybridgecluster/apply.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -22,7 +22,7 @@ func (r *CrunchyBridgeClusterReconciler) patch( patch client.Patch, options ...client.PatchOption, ) error { options = append([]client.PatchOption{r.Owner}, options...) - return r.Client.Patch(ctx, object, patch, options...) + return r.Patch(ctx, object, patch, options...) } // apply sends an apply patch to object's endpoint in the Kubernetes API and diff --git a/internal/bridge/crunchybridgecluster/crunchybridgecluster_controller.go b/internal/bridge/crunchybridgecluster/crunchybridgecluster_controller.go index 03d67442be..ec9973ade1 100644 --- a/internal/bridge/crunchybridgecluster/crunchybridgecluster_controller.go +++ b/internal/bridge/crunchybridgecluster/crunchybridgecluster_controller.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -20,11 +20,14 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" "github.com/crunchydata/postgres-operator/internal/bridge" "github.com/crunchydata/postgres-operator/internal/controller/runtime" - pgoRuntime "github.com/crunchydata/postgres-operator/internal/controller/runtime" + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/tracing" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -54,15 +57,23 @@ func (r *CrunchyBridgeClusterReconciler) SetupWithManager( For(&v1beta1.CrunchyBridgeCluster{}). Owns(&corev1.Secret{}). // Wake periodically to check Bridge API for all CrunchyBridgeClusters. - // Potentially replace with different requeue times, remove the Watch function - // Smarter: retry after a certain time for each cluster: https://gist.github.com/cbandy/a5a604e3026630c5b08cfbcdfffd2a13 + // Potentially replace with different requeue times + // Smarter: retry after a certain time for each cluster WatchesRawSource( - pgoRuntime.NewTickerImmediate(5*time.Minute, event.GenericEvent{}, r.Watch()), + runtime.NewTickerImmediate(5*time.Minute, event.GenericEvent{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []ctrl.Request { + var list v1beta1.CrunchyBridgeClusterList + _ = r.List(ctx, &list) + return runtime.Requests(initialize.Pointers(list.Items...)...) + }), + ), ). // Watch secrets and filter for secrets mentioned by CrunchyBridgeClusters Watches( &corev1.Secret{}, - r.watchForRelatedSecret(), + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, secret client.Object) []ctrl.Request { + return runtime.Requests(r.findCrunchyBridgeClustersForSecret(ctx, client.ObjectKeyFromObject(secret))...) + }), ). Complete(r) } @@ -80,7 +91,7 @@ func (r *CrunchyBridgeClusterReconciler) SetupWithManager( func (r *CrunchyBridgeClusterReconciler) setControllerReference( owner *v1beta1.CrunchyBridgeCluster, controlled client.Object, ) error { - return controllerutil.SetControllerReference(owner, controlled, r.Client.Scheme()) + return controllerutil.SetControllerReference(owner, controlled, r.Scheme()) } //+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="crunchybridgeclusters",verbs={get,patch,update} @@ -91,7 +102,9 @@ func (r *CrunchyBridgeClusterReconciler) setControllerReference( // Reconcile does the work to move the current state of the world toward the // desired state described in a [v1beta1.CrunchyBridgeCluster] identified by req. func (r *CrunchyBridgeClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - log := ctrl.LoggerFrom(ctx) + ctx, span := tracing.Start(ctx, "reconcile-crunchybridgecluster") + log := logging.FromContext(ctx) + defer span.End() // Retrieve the crunchybridgecluster from the client cache, if it exists. A deferred // function below will send any changes to its Status field. @@ -120,7 +133,7 @@ func (r *CrunchyBridgeClusterReconciler) Reconcile(ctx context.Context, req ctrl // NotFound cannot be fixed by requeuing so ignore it. During background // deletion, we receive delete events from crunchybridgecluster's dependents after // crunchybridgecluster is deleted. - return ctrl.Result{}, client.IgnoreNotFound(err) + return ctrl.Result{}, tracing.Escape(span, client.IgnoreNotFound(err)) } // Get and validate connection secret for requests @@ -139,12 +152,12 @@ func (r *CrunchyBridgeClusterReconciler) Reconcile(ctx context.Context, req ctrl // is not being deleted. if result, err := r.handleDelete(ctx, crunchybridgecluster, key); err != nil { log.Error(err, "deleting") - return ctrl.Result{}, err + return ctrl.Result{}, tracing.Escape(span, err) } else if result != nil { if log := log.V(1); log.Enabled() { log.Info("deleting", "result", fmt.Sprintf("%+v", *result)) } - return *result, err + return *result, tracing.Escape(span, err) } // Wonder if there's a better way to handle adding/checking/removing statuses @@ -177,7 +190,7 @@ func (r *CrunchyBridgeClusterReconciler) Reconcile(ctx context.Context, req ctrl // Check if a cluster with the same name already exists controllerResult, err := r.handleDuplicateClusterName(ctx, key, team, crunchybridgecluster) if err != nil || controllerResult != nil { - return *controllerResult, err + return *controllerResult, tracing.Escape(span, err) } // if we've gotten here then no cluster exists with that name and we're missing the ID, ergo, create cluster @@ -191,26 +204,26 @@ func (r *CrunchyBridgeClusterReconciler) Reconcile(ctx context.Context, req ctrl // Get Cluster err = r.handleGetCluster(ctx, key, crunchybridgecluster) if err != nil { - return ctrl.Result{}, err + return ctrl.Result{}, tracing.Escape(span, err) } // Get Cluster Status err = r.handleGetClusterStatus(ctx, key, crunchybridgecluster) if err != nil { - return ctrl.Result{}, err + return ctrl.Result{}, tracing.Escape(span, err) } // Get Cluster Upgrade err = r.handleGetClusterUpgrade(ctx, key, crunchybridgecluster) if err != nil { - return ctrl.Result{}, err + return ctrl.Result{}, tracing.Escape(span, err) } // Reconcile roles and their secrets err = r.reconcilePostgresRoles(ctx, key, crunchybridgecluster) if err != nil { log.Error(err, "issue reconciling postgres user roles/secrets") - return ctrl.Result{}, err + return ctrl.Result{}, tracing.Escape(span, err) } // For now, we skip updating until the upgrade status is cleared. @@ -671,7 +684,7 @@ func (r *CrunchyBridgeClusterReconciler) GetSecretKeys( }} err := errors.WithStack( - r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing)) + r.Get(ctx, client.ObjectKeyFromObject(existing), existing)) if err == nil { if existing.Data["key"] != nil && existing.Data["team"] != nil { @@ -694,7 +707,7 @@ func (r *CrunchyBridgeClusterReconciler) deleteControlled( version := object.GetResourceVersion() exactly := client.Preconditions{UID: &uid, ResourceVersion: &version} - return r.Client.Delete(ctx, object, exactly) + return r.Delete(ctx, object, exactly) } return nil diff --git a/internal/bridge/crunchybridgecluster/crunchybridgecluster_controller_test.go b/internal/bridge/crunchybridgecluster/crunchybridgecluster_controller_test.go index 92d6b58d0e..a29b418b13 100644 --- a/internal/bridge/crunchybridgecluster/crunchybridgecluster_controller_test.go +++ b/internal/bridge/crunchybridgecluster/crunchybridgecluster_controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/bridge/crunchybridgecluster/delete.go b/internal/bridge/crunchybridgecluster/delete.go index 8dcada31cf..ae44c8036b 100644 --- a/internal/bridge/crunchybridgecluster/delete.go +++ b/internal/bridge/crunchybridgecluster/delete.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -28,7 +28,7 @@ func (r *CrunchyBridgeClusterReconciler) handleDelete( log := ctrl.LoggerFrom(ctx) // If the CrunchyBridgeCluster isn't being deleted, add the finalizer - if crunchybridgecluster.ObjectMeta.DeletionTimestamp.IsZero() { + if crunchybridgecluster.DeletionTimestamp.IsZero() { if !controllerutil.ContainsFinalizer(crunchybridgecluster, finalizer) { controllerutil.AddFinalizer(crunchybridgecluster, finalizer) if err := r.Update(ctx, crunchybridgecluster); err != nil { diff --git a/internal/bridge/crunchybridgecluster/delete_test.go b/internal/bridge/crunchybridgecluster/delete_test.go index 28e6feb1f8..c86746ef1b 100644 --- a/internal/bridge/crunchybridgecluster/delete_test.go +++ b/internal/bridge/crunchybridgecluster/delete_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -65,7 +65,7 @@ func TestHandleDeleteCluster(t *testing.T) { // Get cluster from kubernetes and assert that the deletion timestamp was added assert.NilError(t, tClient.Get(ctx, client.ObjectKeyFromObject(cluster), cluster)) - assert.Check(t, !cluster.ObjectMeta.DeletionTimestamp.IsZero()) + assert.Check(t, !cluster.DeletionTimestamp.IsZero()) // Note: We must run handleDelete multiple times because we don't want to remove the // finalizer until we're sure that the cluster has been deleted from Bridge, so we @@ -107,7 +107,7 @@ func TestHandleDeleteCluster(t *testing.T) { // Get cluster from kubernetes and assert that the deletion timestamp was added assert.NilError(t, tClient.Get(ctx, client.ObjectKeyFromObject(cluster), cluster)) - assert.Check(t, !cluster.ObjectMeta.DeletionTimestamp.IsZero()) + assert.Check(t, !cluster.DeletionTimestamp.IsZero()) // Run handleDelete again to attempt to delete from Bridge, but provide bad api key cluster.Status.ID = "2345" diff --git a/internal/bridge/crunchybridgecluster/helpers_test.go b/internal/bridge/crunchybridgecluster/helpers_test.go index f40ad3d054..f8bc4295f6 100644 --- a/internal/bridge/crunchybridgecluster/helpers_test.go +++ b/internal/bridge/crunchybridgecluster/helpers_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/bridge/crunchybridgecluster/mock_bridge_api.go b/internal/bridge/crunchybridgecluster/mock_bridge_api.go index 5c6b243714..f0439531d1 100644 --- a/internal/bridge/crunchybridgecluster/mock_bridge_api.go +++ b/internal/bridge/crunchybridgecluster/mock_bridge_api.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,7 +13,6 @@ import ( "github.com/crunchydata/postgres-operator/internal/bridge" "github.com/crunchydata/postgres-operator/internal/initialize" - "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) diff --git a/internal/bridge/crunchybridgecluster/postgres.go b/internal/bridge/crunchybridgecluster/postgres.go index 024631de67..80096de91b 100644 --- a/internal/bridge/crunchybridgecluster/postgres.go +++ b/internal/bridge/crunchybridgecluster/postgres.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -11,7 +11,6 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -93,7 +92,7 @@ func (r *CrunchyBridgeClusterReconciler) reconcilePostgresRoleSecrets( // Make sure that this cluster's role secret names are not being used by any other // secrets in the namespace allSecretsInNamespace := &corev1.SecretList{} - err := errors.WithStack(r.Client.List(ctx, allSecretsInNamespace, client.InNamespace(cluster.Namespace))) + err := errors.WithStack(r.List(ctx, allSecretsInNamespace, client.InNamespace(cluster.Namespace))) if err != nil { return nil, nil, err } @@ -116,7 +115,7 @@ func (r *CrunchyBridgeClusterReconciler) reconcilePostgresRoleSecrets( selector, err := naming.AsSelector(naming.CrunchyBridgeClusterPostgresRoles(cluster.Name)) if err == nil { err = errors.WithStack( - r.Client.List(ctx, secrets, + r.List(ctx, secrets, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector}, )) diff --git a/internal/bridge/crunchybridgecluster/postgres_test.go b/internal/bridge/crunchybridgecluster/postgres_test.go index 66add7b789..6fae4fe26a 100644 --- a/internal/bridge/crunchybridgecluster/postgres_test.go +++ b/internal/bridge/crunchybridgecluster/postgres_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -8,12 +8,11 @@ import ( "context" "testing" - "sigs.k8s.io/controller-runtime/pkg/client" - "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crunchydata/postgres-operator/internal/bridge" "github.com/crunchydata/postgres-operator/internal/testing/require" diff --git a/internal/bridge/crunchybridgecluster/watches.go b/internal/bridge/crunchybridgecluster/watches.go index 79687b3476..44a2c1490b 100644 --- a/internal/bridge/crunchybridgecluster/watches.go +++ b/internal/bridge/crunchybridgecluster/watches.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,48 +7,11 @@ package crunchybridgecluster import ( "context" - "k8s.io/client-go/util/workqueue" - 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/handler" - "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) -// watchForRelatedSecret handles create/update/delete events for secrets, -// passing the Secret ObjectKey to findCrunchyBridgeClustersForSecret -func (r *CrunchyBridgeClusterReconciler) watchForRelatedSecret() handler.EventHandler { - handle := func(ctx context.Context, secret client.Object, q workqueue.RateLimitingInterface) { - key := client.ObjectKeyFromObject(secret) - - for _, cluster := range r.findCrunchyBridgeClustersForSecret(ctx, key) { - q.Add(ctrl.Request{ - NamespacedName: client.ObjectKeyFromObject(cluster), - }) - } - } - - return handler.Funcs{ - CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.Object, q) - }, - UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.ObjectNew, q) - }, - // If the secret is deleted, we want to reconcile - // in order to emit an event/status about this problem. - // We will also emit a matching event/status about this problem - // when we reconcile the cluster and can't find the secret. - // That way, users will get two alerts: one when the secret is deleted - // and another when the cluster is being reconciled. - DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.Object, q) - }, - } -} - //+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="crunchybridgeclusters",verbs={list} // findCrunchyBridgeClustersForSecret returns CrunchyBridgeClusters @@ -60,7 +23,7 @@ func (r *CrunchyBridgeClusterReconciler) findCrunchyBridgeClustersForSecret( var clusters v1beta1.CrunchyBridgeClusterList // NOTE: If this becomes slow due to a large number of CrunchyBridgeClusters in a single - // namespace, we can configure the [ctrl.Manager] field indexer and pass a + // namespace, we can configure the [manager.Manager] field indexer and pass a // [fields.Selector] here. // - https://book.kubebuilder.io/reference/watching-resources/externally-managed.html if err := r.List(ctx, &clusters, &client.ListOptions{ @@ -74,30 +37,3 @@ func (r *CrunchyBridgeClusterReconciler) findCrunchyBridgeClustersForSecret( } return matching } - -//+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="crunchybridgeclusters",verbs={list} - -// Watch enqueues all existing CrunchyBridgeClusters for reconciles. -func (r *CrunchyBridgeClusterReconciler) Watch() handler.EventHandler { - return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, _ client.Object) []reconcile.Request { - log := ctrl.LoggerFrom(ctx) - - crunchyBridgeClusterList := &v1beta1.CrunchyBridgeClusterList{} - if err := r.List(ctx, crunchyBridgeClusterList); err != nil { - log.Error(err, "Error listing CrunchyBridgeClusters.") - } - - reconcileRequests := []reconcile.Request{} - for index := range crunchyBridgeClusterList.Items { - reconcileRequests = append(reconcileRequests, - reconcile.Request{ - NamespacedName: client.ObjectKeyFromObject( - &crunchyBridgeClusterList.Items[index], - ), - }, - ) - } - - return reconcileRequests - }) -} diff --git a/internal/bridge/crunchybridgecluster/watches_test.go b/internal/bridge/crunchybridgecluster/watches_test.go index 48dba2ba14..7ac0e26e57 100644 --- a/internal/bridge/crunchybridgecluster/watches_test.go +++ b/internal/bridge/crunchybridgecluster/watches_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/bridge/installation.go b/internal/bridge/installation.go index c76a073348..3464c6f020 100644 --- a/internal/bridge/installation.go +++ b/internal/bridge/installation.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/bridge/installation_test.go b/internal/bridge/installation_test.go index 96223a2233..f7a86e2d3a 100644 --- a/internal/bridge/installation_test.go +++ b/internal/bridge/installation_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -18,10 +18,10 @@ import ( corev1 "k8s.io/api/core/v1" corev1apply "k8s.io/client-go/applyconfigurations/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/yaml" "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" ) func TestExtractSecretContract(t *testing.T) { @@ -99,7 +99,7 @@ func TestInstallationReconcile(t *testing.T) { reconciler.NewClient = func() *Client { c := NewClient(server.URL, "") - c.Backoff.Steps = 1 + c.Steps = 1 assert.Equal(t, c.BaseURL.String(), server.URL) return c } @@ -136,7 +136,7 @@ func TestInstallationReconcile(t *testing.T) { assert.Assert(t, cmp.Contains(applies[0], `"kind":"Secret"`)) var decoded corev1.Secret - assert.NilError(t, yaml.Unmarshal([]byte(applies[0]), &decoded)) + require.UnmarshalInto(t, &decoded, applies[0]) assert.Assert(t, cmp.Contains(string(decoded.Data["bridge-token"]), `"id":"abc"`)) assert.Assert(t, cmp.Contains(string(decoded.Data["bridge-token"]), `"secret":"xyz"`)) }) @@ -155,7 +155,7 @@ func TestInstallationReconcile(t *testing.T) { reconciler.NewClient = func() *Client { c := NewClient(server.URL, "") - c.Backoff.Steps = 1 + c.Steps = 1 assert.Equal(t, c.BaseURL.String(), server.URL) return c } @@ -230,7 +230,7 @@ func TestInstallationReconcile(t *testing.T) { assert.Assert(t, cmp.Contains(applies[0], `"kind":"Secret"`)) var decoded corev1.Secret - assert.NilError(t, yaml.Unmarshal([]byte(applies[0]), &decoded)) + require.UnmarshalInto(t, &decoded, applies[0]) assert.Assert(t, cmp.Contains(string(decoded.Data["bridge-token"]), `"id":"asdf"`)) }) } @@ -289,7 +289,7 @@ func TestInstallationReconcile(t *testing.T) { reconciler.NewClient = func() *Client { c := NewClient(server.URL, "") - c.Backoff.Steps = 1 + c.Steps = 1 assert.Equal(t, c.BaseURL.String(), server.URL) return c } @@ -326,7 +326,7 @@ func TestInstallationReconcile(t *testing.T) { assert.Assert(t, cmp.Contains(applies[0], `"kind":"Secret"`)) var decoded corev1.Secret - assert.NilError(t, yaml.Unmarshal([]byte(applies[0]), &decoded)) + require.UnmarshalInto(t, &decoded, applies[0]) assert.Assert(t, cmp.Contains(string(decoded.Data["bridge-token"]), `"id":"xyz"`)) assert.Assert(t, cmp.Contains(string(decoded.Data["bridge-token"]), `"secret":"def"`)) }) @@ -343,7 +343,7 @@ func TestInstallationReconcile(t *testing.T) { reconciler.NewClient = func() *Client { c := NewClient(server.URL, "") - c.Backoff.Steps = 1 + c.Steps = 1 assert.Equal(t, c.BaseURL.String(), server.URL) return c } @@ -373,7 +373,7 @@ func TestInstallationReconcile(t *testing.T) { assert.Assert(t, cmp.Contains(applies[0], `"kind":"Secret"`)) var decoded corev1.Secret - assert.NilError(t, yaml.Unmarshal([]byte(applies[0]), &decoded)) + require.UnmarshalInto(t, &decoded, applies[0]) assert.Equal(t, len(decoded.Data["bridge-token"]), 0) archived := string(decoded.Data["bridge-token--2020-10-28"]) @@ -426,7 +426,7 @@ func TestInstallationReconcile(t *testing.T) { reconciler.NewClient = func() *Client { c := NewClient(server.URL, "") - c.Backoff.Steps = 1 + c.Steps = 1 assert.Equal(t, c.BaseURL.String(), server.URL) return c } @@ -463,7 +463,7 @@ func TestInstallationReconcile(t *testing.T) { assert.Assert(t, cmp.Contains(applies[0], `"kind":"Secret"`)) var decoded corev1.Secret - assert.NilError(t, yaml.Unmarshal([]byte(applies[0]), &decoded)) + require.UnmarshalInto(t, &decoded, applies[0]) assert.Assert(t, cmp.Contains(string(decoded.Data["bridge-token"]), `"id":"ddd"`)) assert.Assert(t, cmp.Contains(string(decoded.Data["bridge-token"]), `"secret":"fresh"`)) }) diff --git a/internal/bridge/naming.go b/internal/bridge/naming.go index cabe8e9cf6..7b8b6a9223 100644 --- a/internal/bridge/naming.go +++ b/internal/bridge/naming.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/bridge/quantity.go b/internal/bridge/quantity.go index a948c6b4cf..e4edd2a149 100644 --- a/internal/bridge/quantity.go +++ b/internal/bridge/quantity.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/bridge/quantity_test.go b/internal/bridge/quantity_test.go index 7cfebb4a86..d0e914a9d1 100644 --- a/internal/bridge/quantity_test.go +++ b/internal/bridge/quantity_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/collector/config.go b/internal/collector/config.go new file mode 100644 index 0000000000..dc1e579a70 --- /dev/null +++ b/internal/collector/config.go @@ -0,0 +1,229 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + _ "embed" + "fmt" + "maps" + "math" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/yaml" + + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// The contents of "logrotate.conf" as a string. +// See: https://pkg.go.dev/embed +// +//go:embed "logrotate.conf" +var logrotateConfigFormatString string + +// ComponentID represents a component identifier within an OpenTelemetry +// Collector YAML configuration. Each value is a "type" followed by an optional +// slash-then-name: `type[/name]` +type ComponentID = string + +// PipelineID represents a pipeline identifier within an OpenTelemetry Collector +// YAML configuration. Each value is a signal followed by an optional +// slash-then-name: `signal[/name]` +type PipelineID = string + +// Config represents an OpenTelemetry Collector YAML configuration. +// See: https://opentelemetry.io/docs/collector/configuration +type Config struct { + Exporters map[ComponentID]any + Extensions map[ComponentID]any + Processors map[ComponentID]any + Receivers map[ComponentID]any + + Pipelines map[PipelineID]Pipeline +} + +// Pipeline represents the YAML configuration of a flow of telemetry data +// through an OpenTelemetry Collector. +// See: https://opentelemetry.io/docs/collector/configuration#pipelines +type Pipeline struct { + Extensions []ComponentID + Exporters []ComponentID + Processors []ComponentID + Receivers []ComponentID +} + +// LogrotateConfig represents the configurable pieces of a log rotate config +// that can vary based on the specific component whose logs are being rotated +type LogrotateConfig struct { + LogFiles []string + PostrotateScript string +} + +func (c *Config) ToYAML() (string, error) { + const yamlGeneratedWarning = "" + + "# Generated by postgres-operator. DO NOT EDIT.\n" + + "# Your changes will not be saved.\n" + + extensions := sets.New[ComponentID]() + pipelines := make(map[PipelineID]any, len(c.Pipelines)) + + for id, p := range c.Pipelines { + extensions.Insert(p.Extensions...) + pipelines[id] = map[string]any{ + "exporters": p.Exporters, + "processors": p.Processors, + "receivers": p.Receivers, + } + } + + b, err := yaml.Marshal(map[string]any{ + "exporters": c.Exporters, + "extensions": c.Extensions, + "processors": c.Processors, + "receivers": c.Receivers, + "service": map[string]any{ + "extensions": sets.List(extensions), // Sorted + "pipelines": pipelines, + }, + }) + return string(append([]byte(yamlGeneratedWarning), b...)), err +} + +// NewConfig creates a base config for an OTel collector container +func NewConfig(spec *v1beta1.InstrumentationSpec) *Config { + config := &Config{ + Exporters: map[ComponentID]any{ + // https://pkg.go.dev/go.opentelemetry.io/collector/exporter/debugexporter#section-readme + DebugExporter: map[string]any{"verbosity": "detailed"}, + }, + Extensions: map[ComponentID]any{}, + Processors: map[ComponentID]any{ + // https://pkg.go.dev/go.opentelemetry.io/collector/processor/batchprocessor#section-readme + OneSecondBatchProcessor: map[string]any{"timeout": "1s"}, + SubSecondBatchProcessor: map[string]any{"timeout": "200ms"}, + + // https://pkg.go.dev/github.com/open-telemetry/opentelemetry-collector-contrib/processor/groupbyattrsprocessor#readme-compaction + CompactingProcessor: map[string]any{}, + }, + Receivers: map[ComponentID]any{}, + Pipelines: map[PipelineID]Pipeline{}, + } + + // Configure a batch processor for logs according to the API spec. + // Use API defaults for any unspecified fields. + { + var batches v1beta1.OpenTelemetryLogsBatchSpec + if spec != nil && spec.Logs != nil && spec.Logs.Batches != nil { + spec.Logs.Batches.DeepCopyInto(&batches) + } + batches.Default() + + // https://pkg.go.dev/go.opentelemetry.io/collector/processor/batchprocessor#section-readme + processor := map[string]any{} + if batches.MaxDelay != nil { + processor["timeout"] = batches.MaxDelay.AsDuration().Duration.String() + } + if batches.MaxRecords != nil { + processor["send_batch_max_size"] = *batches.MaxRecords + } + if batches.MinRecords != nil { + processor["send_batch_size"] = *batches.MinRecords + } + config.Processors[LogsBatchProcessor] = processor + } + + // Create a resource detection processor according to the API spec. + // When nothing is specified, the processor does nothing. + { + // https://pkg.go.dev/github.com/open-telemetry/opentelemetry-collector-contrib/processor/resourcedetectionprocessor#section-readme + processor := map[string]any{"override": false, "timeout": "30s"} + + if spec != nil && spec.Config != nil { + names := make([]string, len(spec.Config.Detectors)) + for i, detector := range spec.Config.Detectors { + names[i] = detector.Name + + if len(detector.Attributes) > 0 { + attributes := make(map[string]any, len(detector.Attributes)) + for k, v := range detector.Attributes { + attributes[k] = map[string]any{"enabled": v} + } + processor[detector.Name] = map[string]any{ + "resource_attributes": attributes, + } + } + } + processor["detectors"] = names + } else { + processor["detectors"] = []string{} + } + + config.Processors[ResourceDetectionProcessor] = processor + } + + // If there are exporters defined in the spec, add them to the config. + if spec != nil && spec.Config != nil && spec.Config.Exporters != nil { + maps.Copy(config.Exporters, spec.Config.Exporters) + } + + return config +} + +// AddLogrotateConfigs generates a logrotate configuration for each LogrotateConfig +// provided via the configs parameter and adds them to the provided configmap. +func AddLogrotateConfigs(ctx context.Context, spec *v1beta1.InstrumentationSpec, + outInstanceConfigMap *corev1.ConfigMap, configs []LogrotateConfig, +) { + if outInstanceConfigMap.Data == nil { + outInstanceConfigMap.Data = make(map[string]string) + } + + // If retentionPeriod is set in the spec, use that value; otherwise, we want + // to use a reasonably short duration. Defaulting to 1 day. + retentionPeriod := metav1.Duration{Duration: 24 * time.Hour} + if spec != nil && spec.Logs != nil && spec.Logs.RetentionPeriod != nil { + retentionPeriod = spec.Logs.RetentionPeriod.AsDuration() + } + + logrotateConfig := "" + for _, config := range configs { + logrotateConfig += generateLogrotateConfig(config, retentionPeriod) + } + + outInstanceConfigMap.Data["logrotate.conf"] = logrotateConfig +} + +// generateLogrotateConfig generates a configuration string for logrotate based +// on the provided full log file path, retention period, and postrotate script +func generateLogrotateConfig( + config LogrotateConfig, retentionPeriod metav1.Duration, +) string { + number, interval := ParseDurationForLogrotate(retentionPeriod) + + return fmt.Sprintf( + logrotateConfigFormatString, + strings.Join(config.LogFiles, " "), + number, + interval, + config.PostrotateScript, + ) +} + +// ParseDurationForLogrotate takes a retention period and returns the rotate +// number and interval string that should be used in the logrotate config. +// If the retentionPeriod is less than 24 hours, the function will return the +// number of hours and "hourly"; otherwise, we will round up to the nearest day +// and return the day count and "daily" +func ParseDurationForLogrotate(retentionPeriod metav1.Duration) (int, string) { + hours := math.Ceil(retentionPeriod.Hours()) + if hours < 24 { + return int(hours), "hourly" + } + return int(math.Ceil(hours / 24)), "daily" +} diff --git a/internal/collector/config_test.go b/internal/collector/config_test.go new file mode 100644 index 0000000000..ce4dd7af7b --- /dev/null +++ b/internal/collector/config_test.go @@ -0,0 +1,287 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "testing" + + "gotest.tools/v3/assert" + + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestConfigToYAML(t *testing.T) { + t.Run("NilInstrumentationSpec", func(t *testing.T) { + result, err := NewConfig(nil).ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed +extensions: {} +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resourcedetection: + detectors: [] + override: false + timeout: 30s +receivers: {} +service: + extensions: [] + pipelines: {} +`) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + spec := testInstrumentationSpec() + + result, err := NewConfig(spec).ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name +extensions: {} +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resourcedetection: + detectors: [] + override: false + timeout: 30s +receivers: {} +service: + extensions: [] + pipelines: {} +`) + }) + + t.Run("LogsBatches", func(t *testing.T) { + var spec *v1beta1.InstrumentationSpec + require.UnmarshalInto(t, &spec, `{ + logs: { + batches: { + maxDelay: 5min 12sec, + maxRecords: 123, + minRecords: 45, + }, + }, + }`) + + result, err := NewConfig(spec).ToYAML() + assert.NilError(t, err) + assert.Assert(t, cmp.Contains(result, ` + batch/logs: + send_batch_max_size: 123 + send_batch_size: 45 + timeout: 5m12s +`)) + + t.Run("Disable", func(t *testing.T) { + var spec *v1beta1.InstrumentationSpec + require.UnmarshalInto(t, &spec, `{ + logs: { + batches: { minRecords: 0, maxDelay: "0" }, + }, + }`) + + result, err := NewConfig(spec).ToYAML() + assert.NilError(t, err) + assert.Assert(t, cmp.Contains(result, ` + batch/logs: + send_batch_size: 0 + timeout: 0s +`)) + }) + }) + + t.Run("Detectors", func(t *testing.T) { + var spec *v1beta1.InstrumentationSpec + require.UnmarshalInto(t, &spec, `{ + config: { + detectors: [ + { name: gcp }, + { name: aks, attributes: { k8s.cluster.name: true } }, + ], + }, + }`) + + result, err := NewConfig(spec).ToYAML() + assert.NilError(t, err) + assert.Assert(t, cmp.Contains(result, ` + resourcedetection: + aks: + resource_attributes: + k8s.cluster.name: + enabled: true + detectors: + - gcp + - aks + override: false + timeout: 30s +`)) + }) +} + +func TestGenerateLogrotateConfig(t *testing.T) { + for _, tt := range []struct { + config LogrotateConfig + retentionPeriod string + result string + }{ + { + config: LogrotateConfig{ + LogFiles: []string{"/this/is/a/file.path"}, + PostrotateScript: "echo 'Hello, World'", + }, + retentionPeriod: "12h", + result: `/this/is/a/file.path { + rotate 12 + missingok + sharedscripts + notifempty + nocompress + hourly + postrotate + echo 'Hello, World' + endscript +} +`, + }, + { + config: LogrotateConfig{ + LogFiles: []string{"/tmp/test.log"}, + PostrotateScript: "", + }, + retentionPeriod: "5 days", + result: `/tmp/test.log { + rotate 5 + missingok + sharedscripts + notifempty + nocompress + daily + postrotate + + endscript +} +`, + }, + { + config: LogrotateConfig{ + LogFiles: []string{"/tmp/test.csv", "/tmp/test.json"}, + PostrotateScript: "pkill -HUP --exact pgbouncer", + }, + retentionPeriod: "5wk", + result: `/tmp/test.csv /tmp/test.json { + rotate 35 + missingok + sharedscripts + notifempty + nocompress + daily + postrotate + pkill -HUP --exact pgbouncer + endscript +} +`, + }, + } { + t.Run(tt.retentionPeriod, func(t *testing.T) { + duration, err := v1beta1.NewDuration(tt.retentionPeriod) + assert.NilError(t, err) + result := generateLogrotateConfig(tt.config, duration.AsDuration()) + assert.Equal(t, tt.result, result) + }) + } +} + +func TestParseDurationForLogrotate(t *testing.T) { + for _, tt := range []struct { + retentionPeriod string + number int + interval string + }{ + { + retentionPeriod: "1 h 20 min", + number: 2, + interval: "hourly", + }, + { + retentionPeriod: "12h", + number: 12, + interval: "hourly", + }, + { + retentionPeriod: "24hr", + number: 1, + interval: "daily", + }, + { + retentionPeriod: "35hour", + number: 2, + interval: "daily", + }, + { + retentionPeriod: "36 hours", + number: 2, + interval: "daily", + }, + { + retentionPeriod: "3d", + number: 3, + interval: "daily", + }, + { + retentionPeriod: "365day", + number: 365, + interval: "daily", + }, + { + retentionPeriod: "1w", + number: 7, + interval: "daily", + }, + { + retentionPeriod: "4wk", + number: 28, + interval: "daily", + }, + { + retentionPeriod: "52week", + number: 364, + interval: "daily", + }, + } { + t.Run(tt.retentionPeriod, func(t *testing.T) { + duration, err := v1beta1.NewDuration(tt.retentionPeriod) + assert.NilError(t, err) + number, interval := ParseDurationForLogrotate(duration.AsDuration()) + assert.Equal(t, tt.number, number) + assert.Equal(t, tt.interval, interval) + }) + } +} diff --git a/internal/collector/eq_pg16_fast_metrics.yaml b/internal/collector/eq_pg16_fast_metrics.yaml new file mode 100644 index 0000000000..855dc8a3d3 --- /dev/null +++ b/internal/collector/eq_pg16_fast_metrics.yaml @@ -0,0 +1,51 @@ +# This list of queries configures an OTel SQL Query Receiver to read pgMonitor +# metrics from Postgres. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/sqlqueryreceiver#metrics-queries +# https://github.com/CrunchyData/pgmonitor/blob/development/sql_exporter/common/crunchy_global_collector.yml + +# NOTE: Some of the columns below can return NULL values, for which sqlqueryreceiver will warn. +# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/sqlqueryreceiver#null-values +# Those columns are retained_bytes, database, and conflicting and we avoid NULL by using COALESCE. + - sql: > + SELECT + s.slot_name + , s.active::int + , COALESCE(pg_wal_lsn_diff(CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_insert_lsn() END, s.restart_lsn), 0) AS retained_bytes + , COALESCE(s.database, '') + , s.slot_type + , COALESCE(s.conflicting::int, 0) + , 0 AS failover + , 0 AS synced + FROM pg_catalog.pg_replication_slots s; + metrics: + - metric_name: ccp_replication_slots_active + value_column: active + description: Active state of slot. 1 = true. 0 = false. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_retained_bytes + value_column: retained_bytes + description: The amount of WAL (in bytes) being retained for this slot + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_conflicting + value_column: conflicting + description: True if this logical slot conflicted with recovery (and so is now invalidated). When this column is true, check invalidation_reason column for the conflict reason. Always NULL for physical slots. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_failover + value_column: failover + description: True if this is a logical slot enabled to be synced to the standbys so that logical replication can be resumed from the new primary after failover. Always false for physical slots. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_synced + value_column: synced + description: True if this is a logical slot that was synced from a primary server. On a hot standby, the slots with the synced column marked as true can neither be used for logical decoding nor dropped manually. The value of this column has no meaning on the primary server; the column value on the primary is default false for all slots but may (if leftover from a promoted standby) also be true. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" diff --git a/internal/collector/generate.go b/internal/collector/generate.go new file mode 100644 index 0000000000..0225be782b --- /dev/null +++ b/internal/collector/generate.go @@ -0,0 +1,60 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +// [pg_query.Parse] requires CGO to compile and call https://github.com/pganalyze/libpg_query +//go:build cgo && generate + +//go:generate go run generate.go + +package main + +import ( + "bytes" + "log/slog" + "os" + "path/filepath" + "strings" + + pg_query "github.com/pganalyze/pg_query_go/v6" + "sigs.k8s.io/yaml" +) + +func main() { + cwd := need(os.Getwd()) + fileNames := map[string][]string{} + + slog.Info("Reading", "directory", cwd) + for _, entry := range need(os.ReadDir(cwd)) { + if entry.Type() == 0 { + ext := filepath.Ext(entry.Name()) + fileNames[ext] = append(fileNames[ext], entry.Name()) + } + } + + for _, sqlName := range fileNames[".sql"] { + slog.Info("Reading", "file", sqlName) + sqlData := need(pg_query.Parse(string(need(os.ReadFile(sqlName))))) + sqlPath := filepath.Join("generated", sqlName) + + slog.Info("Writing", "file", sqlPath) + must(os.WriteFile(sqlPath, []byte(need(pg_query.Deparse(sqlData))+"\n"), 0o644)) + } + + for _, yamlName := range fileNames[".yaml"] { + slog.Info("Reading", "file", yamlName) + jsonData := need(yaml.YAMLToJSONStrict(need(os.ReadFile(yamlName)))) + jsonPath := filepath.Join("generated", strings.TrimSuffix(yamlName, ".yaml")+".json") + + slog.Info("Writing", "file", jsonPath) + must(os.WriteFile(jsonPath, append(bytes.TrimSpace(jsonData), '\n'), 0o644)) + } +} + +func must(err error) { need(0, err) } +func need[V any](v V, err error) V { + if err != nil { + panic(err) + } + return v +} diff --git a/internal/collector/generated/eq_pg16_fast_metrics.json b/internal/collector/generated/eq_pg16_fast_metrics.json new file mode 100644 index 0000000000..a695d811d9 --- /dev/null +++ b/internal/collector/generated/eq_pg16_fast_metrics.json @@ -0,0 +1 @@ +[{"metrics":[{"attribute_columns":["database","slot_name","slot_type"],"description":"Active state of slot. 1 = true. 0 = false.","metric_name":"ccp_replication_slots_active","static_attributes":{"server":"localhost:5432"},"value_column":"active"},{"attribute_columns":["database","slot_name","slot_type"],"description":"The amount of WAL (in bytes) being retained for this slot","metric_name":"ccp_replication_slots_retained_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"retained_bytes"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this logical slot conflicted with recovery (and so is now invalidated). When this column is true, check invalidation_reason column for the conflict reason. Always NULL for physical slots.","metric_name":"ccp_replication_slots_conflicting","static_attributes":{"server":"localhost:5432"},"value_column":"conflicting"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this is a logical slot enabled to be synced to the standbys so that logical replication can be resumed from the new primary after failover. Always false for physical slots.","metric_name":"ccp_replication_slots_failover","static_attributes":{"server":"localhost:5432"},"value_column":"failover"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this is a logical slot that was synced from a primary server. On a hot standby, the slots with the synced column marked as true can neither be used for logical decoding nor dropped manually. The value of this column has no meaning on the primary server; the column value on the primary is default false for all slots but may (if leftover from a promoted standby) also be true.","metric_name":"ccp_replication_slots_synced","static_attributes":{"server":"localhost:5432"},"value_column":"synced"}],"sql":"SELECT\n s.slot_name\n , s.active::int\n , COALESCE(pg_wal_lsn_diff(CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_insert_lsn() END, s.restart_lsn), 0) AS retained_bytes\n , COALESCE(s.database, '')\n , s.slot_type\n , COALESCE(s.conflicting::int, 0)\n , 0 AS failover\n , 0 AS synced\nFROM pg_catalog.pg_replication_slots s;\n"}] diff --git a/internal/collector/generated/gte_pg17_fast_metrics.json b/internal/collector/generated/gte_pg17_fast_metrics.json new file mode 100644 index 0000000000..9553e8c756 --- /dev/null +++ b/internal/collector/generated/gte_pg17_fast_metrics.json @@ -0,0 +1 @@ +[{"metrics":[{"data_type":"sum","description":"Number of write operations by background writers","metric_name":"ccp_stat_io_bgwriter_writes","static_attributes":{"server":"localhost:5432"},"value_column":"writes"},{"data_type":"sum","description":"Number of fsync calls by background writers","metric_name":"ccp_stat_io_bgwriter_fsyncs","static_attributes":{"server":"localhost:5432"},"value_column":"fsyncs"}],"sql":"SELECT\n s.writes\n , s.fsyncs\nFROM pg_catalog.pg_stat_io s WHERE backend_type = 'background writer';\n"},{"metrics":[{"description":"Number of scheduled checkpoints that have been performed","metric_name":"ccp_stat_checkpointer_num_timed","static_attributes":{"server":"localhost:5432"},"value_column":"num_timed"},{"description":"Number of requested checkpoints that have been performed","metric_name":"ccp_stat_checkpointer_num_requested","static_attributes":{"server":"localhost:5432"},"value_column":"num_requested"},{"description":"Total amount of time that has been spent in the portion of checkpoint processing where files are written to disk, in milliseconds","metric_name":"ccp_stat_checkpointer_write_time","static_attributes":{"server":"localhost:5432"},"value_column":"write_time","value_type":"double"},{"description":"Total amount of time that has been spent in the portion of checkpoint processing where files are synchronized to disk, in milliseconds","metric_name":"ccp_stat_checkpointer_sync_time","static_attributes":{"server":"localhost:5432"},"value_column":"sync_time"},{"description":"Number of buffers written during checkpoints and restartpoints","metric_name":"ccp_stat_checkpointer_buffers_written","static_attributes":{"server":"localhost:5432"},"value_column":"buffers_written"}],"sql":"SELECT\n c.num_timed\n , c.num_requested\n , c.write_time\n , c.sync_time\n , c.buffers_written\nFROM pg_catalog.pg_stat_checkpointer c;\n"},{"metrics":[{"attribute_columns":["database","slot_name","slot_type"],"description":"Active state of slot. 1 = true. 0 = false.","metric_name":"ccp_replication_slots_active","static_attributes":{"server":"localhost:5432"},"value_column":"active"},{"attribute_columns":["database","slot_name","slot_type"],"description":"The amount of WAL (in bytes) being retained for this slot","metric_name":"ccp_replication_slots_retained_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"retained_bytes"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this logical slot conflicted with recovery (and so is now invalidated). When this column is true, check invalidation_reason column for the conflict reason. Always NULL for physical slots.","metric_name":"ccp_replication_slots_conflicting","static_attributes":{"server":"localhost:5432"},"value_column":"conflicting"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this is a logical slot enabled to be synced to the standbys so that logical replication can be resumed from the new primary after failover. Always false for physical slots.","metric_name":"ccp_replication_slots_failover","static_attributes":{"server":"localhost:5432"},"value_column":"failover"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this is a logical slot that was synced from a primary server. On a hot standby, the slots with the synced column marked as true can neither be used for logical decoding nor dropped manually. The value of this column has no meaning on the primary server; the column value on the primary is default false for all slots but may (if leftover from a promoted standby) also be true.","metric_name":"ccp_replication_slots_synced","static_attributes":{"server":"localhost:5432"},"value_column":"synced"}],"sql":"SELECT\n s.slot_name\n , s.active::int\n , COALESCE(pg_wal_lsn_diff(CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_insert_lsn() END, s.restart_lsn), 0) AS retained_bytes\n , COALESCE(s.database, '')\n , s.slot_type\n , COALESCE(s.conflicting::int, 0)\n , COALESCE(s.failover::int, 0)\n , COALESCE(s.synced::int, 0)\nFROM pg_catalog.pg_replication_slots s;\n"}] diff --git a/internal/collector/generated/lt_pg16_fast_metrics.json b/internal/collector/generated/lt_pg16_fast_metrics.json new file mode 100644 index 0000000000..dcd1d5fe77 --- /dev/null +++ b/internal/collector/generated/lt_pg16_fast_metrics.json @@ -0,0 +1 @@ +[{"metrics":[{"attribute_columns":["database","slot_name","slot_type"],"description":"Active state of slot. 1 = true. 0 = false.","metric_name":"ccp_replication_slots_active","static_attributes":{"server":"localhost:5432"},"value_column":"active"},{"attribute_columns":["database","slot_name","slot_type"],"description":"The amount of WAL (in bytes) being retained for this slot","metric_name":"ccp_replication_slots_retained_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"retained_bytes"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this logical slot conflicted with recovery (and so is now invalidated). When this column is true, check invalidation_reason column for the conflict reason. Always NULL for physical slots.","metric_name":"ccp_replication_slots_conflicting","static_attributes":{"server":"localhost:5432"},"value_column":"conflicting"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this is a logical slot enabled to be synced to the standbys so that logical replication can be resumed from the new primary after failover. Always false for physical slots.","metric_name":"ccp_replication_slots_failover","static_attributes":{"server":"localhost:5432"},"value_column":"failover"},{"attribute_columns":["database","slot_name","slot_type"],"description":"True if this is a logical slot that was synced from a primary server. On a hot standby, the slots with the synced column marked as true can neither be used for logical decoding nor dropped manually. The value of this column has no meaning on the primary server; the column value on the primary is default false for all slots but may (if leftover from a promoted standby) also be true.","metric_name":"ccp_replication_slots_synced","static_attributes":{"server":"localhost:5432"},"value_column":"synced"}],"sql":"SELECT\n s.slot_name\n , s.active::int\n , COALESCE(pg_wal_lsn_diff(CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_insert_lsn() END, s.restart_lsn), 0) AS retained_bytes\n , COALESCE(s.database, '')\n , s.slot_type\n , 0 AS conflicting\n , 0 AS failover\n , 0 AS synced\nFROM pg_catalog.pg_replication_slots s;\n"}] diff --git a/internal/collector/generated/lt_pg17_fast_metrics.json b/internal/collector/generated/lt_pg17_fast_metrics.json new file mode 100644 index 0000000000..55b6ca78fc --- /dev/null +++ b/internal/collector/generated/lt_pg17_fast_metrics.json @@ -0,0 +1 @@ +[{"metrics":[{"data_type":"sum","description":"Number of write operations by background writers","metric_name":"ccp_stat_io_bgwriter_writes","static_attributes":{"server":"localhost:5432"},"value_column":"writes"},{"data_type":"sum","description":"Number of fsync calls by background writers","metric_name":"ccp_stat_io_bgwriter_fsyncs","static_attributes":{"server":"localhost:5432"},"value_column":"fsyncs"}],"sql":"SELECT\n s.buffers_backend AS writes\n , s.buffers_backend_fsync AS fsyncs\nFROM pg_catalog.pg_stat_bgwriter s;\n"},{"metrics":[{"description":"Number of scheduled checkpoints that have been performed","metric_name":"ccp_stat_checkpointer_num_timed","static_attributes":{"server":"localhost:5432"},"value_column":"num_timed"},{"description":"Number of requested checkpoints that have been performed","metric_name":"ccp_stat_checkpointer_num_requested","static_attributes":{"server":"localhost:5432"},"value_column":"num_requested"},{"description":"Total amount of time that has been spent in the portion of checkpoint processing where files are written to disk, in milliseconds","metric_name":"ccp_stat_checkpointer_write_time","static_attributes":{"server":"localhost:5432"},"value_column":"write_time","value_type":"double"},{"description":"Total amount of time that has been spent in the portion of checkpoint processing where files are synchronized to disk, in milliseconds","metric_name":"ccp_stat_checkpointer_sync_time","static_attributes":{"server":"localhost:5432"},"value_column":"sync_time"},{"description":"Number of buffers written during checkpoints and restartpoints","metric_name":"ccp_stat_checkpointer_buffers_written","static_attributes":{"server":"localhost:5432"},"value_column":"buffers_written"}],"sql":"SELECT\n c.checkpoints_timed AS num_timed\n , c.checkpoints_req AS num_requested\n , c.checkpoint_write_time AS write_time\n , c.checkpoint_sync_time AS sync_time\n , c.buffers_checkpoint AS buffers_written\nFROM pg_catalog.pg_stat_bgwriter c;\n"}] diff --git a/internal/collector/generated/pgbackrest_logs_transforms.json b/internal/collector/generated/pgbackrest_logs_transforms.json new file mode 100644 index 0000000000..3f8cf5137a --- /dev/null +++ b/internal/collector/generated/pgbackrest_logs_transforms.json @@ -0,0 +1 @@ +[{"statements":["set(instrumentation_scope.name, \"pgbackrest\")","set(instrumentation_scope.schema_url, \"https://opentelemetry.io/schemas/1.29.0\")","merge_maps(log.cache, ExtractPatterns(log.body, \"^(?\u003ctimestamp\u003e\\\\d{4}-\\\\d{2}-\\\\d{2} \\\\d{2}:\\\\d{2}:\\\\d{2}\\\\.\\\\d{3}) (?\u003cprocess_id\u003eP\\\\d{2,3})\\\\s*(?\u003cerror_severity\u003e\\\\S*): (?\u003cmessage\u003e(?s).*)$\"), \"insert\") where Len(log.body) \u003e 0","set(log.severity_text, log.cache[\"error_severity\"]) where IsString(log.cache[\"error_severity\"])","set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == \"TRACE\"","set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == \"DEBUG\"","set(log.severity_number, SEVERITY_NUMBER_DEBUG2) where log.severity_text == \"DETAIL\"","set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == \"INFO\"","set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == \"WARN\"","set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == \"ERROR\"","set(log.time, Time(log.cache[\"timestamp\"], \"%Y-%m-%d %H:%M:%S.%L\")) where IsString(log.cache[\"timestamp\"])","set(log.attributes[\"process.pid\"], log.cache[\"process_id\"])","set(log.attributes[\"log.record.original\"], log.body)","set(log.body, log.cache[\"message\"])"]}] diff --git a/internal/collector/generated/pgbouncer_metrics_queries.json b/internal/collector/generated/pgbouncer_metrics_queries.json new file mode 100644 index 0000000000..21ebb140bc --- /dev/null +++ b/internal/collector/generated/pgbouncer_metrics_queries.json @@ -0,0 +1 @@ +[{"metrics":[{"attribute_columns":["database","user","state","application_name","link"],"description":"Current waiting time in seconds","metric_name":"ccp_pgbouncer_clients_wait_seconds","value_column":"wait"}],"sql":"SHOW CLIENTS;"},{"metrics":[{"attribute_columns":["name","port","database"],"description":"Maximum number of server connections","metric_name":"ccp_pgbouncer_databases_pool_size","value_column":"pool_size"},{"attribute_columns":["name","port","database"],"description":"Minimum number of server connections","metric_name":"ccp_pgbouncer_databases_min_pool_size","value_column":"min_pool_size"},{"attribute_columns":["name","port","database"],"description":"Maximum number of additional connections for this database","metric_name":"ccp_pgbouncer_databases_reserve_pool_size","value_column":"reserve_pool_size"},{"attribute_columns":["name","port","database"],"description":"Maximum number of allowed connections for this database, as set by max_db_connections, either globally or per database","metric_name":"ccp_pgbouncer_databases_max_connections","value_column":"max_connections"},{"attribute_columns":["name","port","database"],"description":"Current number of connections for this database","metric_name":"ccp_pgbouncer_databases_current_connections","value_column":"current_connections"},{"attribute_columns":["name","port","database"],"description":"1 if this database is currently paused, else 0","metric_name":"ccp_pgbouncer_databases_paused","value_column":"paused"},{"attribute_columns":["name","port","database"],"description":"1 if this database is currently disabled, else 0","metric_name":"ccp_pgbouncer_databases_disabled","value_column":"disabled"}],"sql":"SHOW DATABASES;"},{"metrics":[{"attribute_columns":["list"],"description":"Count of items registered with pgBouncer","metric_name":"ccp_pgbouncer_lists_item_count","value_column":"items"}],"sql":"SHOW LISTS;"},{"metrics":[{"attribute_columns":["database","user"],"description":"Client connections that are either linked to server connections or are idle with no queries waiting to be processed","metric_name":"ccp_pgbouncer_pools_client_active","value_column":"cl_active"},{"attribute_columns":["database","user"],"description":"Client connections that have sent queries but have not yet got a server connection","metric_name":"ccp_pgbouncer_pools_client_waiting","value_column":"cl_waiting"},{"attribute_columns":["database","user"],"description":"Server connections that are linked to a client","metric_name":"ccp_pgbouncer_pools_server_active","value_column":"sv_active"},{"attribute_columns":["database","user"],"description":"Server connections that are unused and immediately usable for client queries","metric_name":"ccp_pgbouncer_pools_server_idle","value_column":"sv_idle"},{"attribute_columns":["database","user"],"description":"Server connections that have been idle for more than server_check_delay, so they need server_check_query to run on them before they can be used again","metric_name":"ccp_pgbouncer_pools_server_used","value_column":"sv_used"}],"sql":"SHOW POOLS;"},{"metrics":[{"attribute_columns":["database","user","state","application_name","link"],"description":"1 if the connection will be closed as soon as possible, because a configuration file reload or DNS update changed the connection information or RECONNECT was issued","metric_name":"ccp_pgbouncer_servers_close_needed","value_column":"close_needed"}],"sql":"SHOW SERVERS;"}] diff --git a/internal/collector/generated/postgres_5m_metrics.json b/internal/collector/generated/postgres_5m_metrics.json new file mode 100644 index 0000000000..b6ec08762c --- /dev/null +++ b/internal/collector/generated/postgres_5m_metrics.json @@ -0,0 +1 @@ +[{"metrics":[{"attribute_columns":["dbname"],"description":"Database size in bytes","metric_name":"ccp_database_size_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"bytes"}],"sql":"SELECT datname as dbname , pg_catalog.pg_database_size(datname) as bytes FROM pg_catalog.pg_database WHERE datistemplate = false;\n"},{"metrics":[{"description":"Count of sequences that have reached greater than or equal to 75% of their max available numbers.\nFunction monitor.sequence_status() can provide more details if run directly on system.\n","metric_name":"ccp_sequence_exhaustion_count","static_attributes":{"server":"localhost:5432"},"value_column":"count"}],"sql":"SELECT count(*) AS count FROM (\n SELECT CEIL((s.max_value-min_value::NUMERIC+1)/s.increment_by::NUMERIC) AS slots\n , CEIL((COALESCE(s.last_value,s.min_value)-s.min_value::NUMERIC+1)/s.increment_by::NUMERIC) AS used\n FROM pg_catalog.pg_sequences s\n) x WHERE (ROUND(used/slots*100)::int) \u003e 75;\n"},{"metrics":[{"description":"Value of checksum monitoring status for pg_catalog.pg_hba_file_rules (pg_hba.conf).\n0 = valid config. 1 = settings changed.\nSettings history is available for review in the table `monitor.pg_hba_checksum`.\nTo reset current config to valid after alert, run monitor.pg_hba_checksum_set_valid(). Note this will clear the history table.\n","metric_name":"ccp_pg_hba_checksum_status","static_attributes":{"server":"localhost:5432"},"value_column":"status"}],"sql":"SELECT monitor.pg_hba_checksum() AS status;"}] diff --git a/internal/collector/generated/postgres_5m_per_db_metrics.json b/internal/collector/generated/postgres_5m_per_db_metrics.json new file mode 100644 index 0000000000..0478569504 --- /dev/null +++ b/internal/collector/generated/postgres_5m_per_db_metrics.json @@ -0,0 +1 @@ +[{"metrics":[{"attribute_columns":["dbname","schemaname","relname"],"description":"Table size in bytes including indexes","metric_name":"ccp_table_size_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"bytes","value_type":"double"}],"sql":"SELECT current_database() as dbname , n.nspname as schemaname , c.relname , pg_catalog.pg_total_relation_size(c.oid) as bytes FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid WHERE NOT pg_is_other_temp_schema(n.oid) AND relkind IN ('r', 'm', 'f');\n"},{"metrics":[{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of sequential scans initiated on this table","metric_name":"ccp_stat_user_tables_seq_scan","static_attributes":{"server":"localhost:5432"},"value_column":"seq_scan"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of live rows fetched by sequential scans","metric_name":"ccp_stat_user_tables_seq_tup_read","static_attributes":{"server":"localhost:5432"},"value_column":"seq_tup_read"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of index scans initiated on this table","metric_name":"ccp_stat_user_tables_idx_scan","static_attributes":{"server":"localhost:5432"},"value_column":"idx_scan"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of live rows fetched by index scans","metric_name":"ccp_stat_user_tables_idx_tup_fetch","static_attributes":{"server":"localhost:5432"},"value_column":"idx_tup_fetch"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of rows inserted","metric_name":"ccp_stat_user_tables_n_tup_ins","static_attributes":{"server":"localhost:5432"},"value_column":"n_tup_ins"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of rows updated","metric_name":"ccp_stat_user_tables_n_tup_upd","static_attributes":{"server":"localhost:5432"},"value_column":"n_tup_upd"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of rows deleted","metric_name":"ccp_stat_user_tables_n_tup_del","static_attributes":{"server":"localhost:5432"},"value_column":"n_tup_del"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of rows HOT updated (i.e., with no separate index update required)","metric_name":"ccp_stat_user_tables_n_tup_hot_upd","static_attributes":{"server":"localhost:5432"},"value_column":"n_tup_hot_upd"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of rows updated where the successor version goes onto a new heap page, leaving behind an original version with a t_ctid field that points to a different heap page. These are always non-HOT updates.","metric_name":"ccp_stat_user_tables_n_tup_newpage_upd","static_attributes":{"server":"localhost:5432"},"value_column":"n_tup_newpage_upd"},{"attribute_columns":["dbname","schemaname","relname"],"description":"Estimated number of live rows","metric_name":"ccp_stat_user_tables_n_live_tup","static_attributes":{"server":"localhost:5432"},"value_column":"n_live_tup"},{"attribute_columns":["dbname","schemaname","relname"],"description":"Estimated number of dead rows","metric_name":"ccp_stat_user_tables_n_dead_tup","static_attributes":{"server":"localhost:5432"},"value_column":"n_dead_tup"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of times this table has been manually vacuumed (not counting VACUUM FULL)","metric_name":"ccp_stat_user_tables_vacuum_count","static_attributes":{"server":"localhost:5432"},"value_column":"vacuum_count"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of times this table has been vacuumed by the autovacuum daemon","metric_name":"ccp_stat_user_tables_autovacuum_count","static_attributes":{"server":"localhost:5432"},"value_column":"autovacuum_count"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of times this table has been manually analyzed","metric_name":"ccp_stat_user_tables_analyze_count","static_attributes":{"server":"localhost:5432"},"value_column":"analyze_count"},{"attribute_columns":["dbname","schemaname","relname"],"data_type":"sum","description":"Number of times this table has been analyzed by the autovacuum daemon","metric_name":"ccp_stat_user_tables_autoanalyze_count","static_attributes":{"server":"localhost:5432"},"value_column":"autoanalyze_count"}],"sql":"SELECT current_database() as dbname , p.schemaname , p.relname , p.seq_scan , p.seq_tup_read , COALESCE(p.idx_scan, 0) AS idx_scan , COALESCE(p.idx_tup_fetch, 0) as idx_tup_fetch , p.n_tup_ins , p.n_tup_upd , p.n_tup_del , p.n_tup_hot_upd , CASE\n WHEN current_setting('server_version_num')::int \u003e= 160000 \n THEN p.n_tup_newpage_upd\n ELSE 0::bigint\n END AS n_tup_newpage_upd\n, p.n_live_tup , p.n_dead_tup , p.vacuum_count , p.autovacuum_count , p.analyze_count , p.autoanalyze_count FROM pg_catalog.pg_stat_user_tables p;\n"}] diff --git a/internal/collector/generated/postgres_5s_metrics.json b/internal/collector/generated/postgres_5s_metrics.json new file mode 100644 index 0000000000..dda612ae59 --- /dev/null +++ b/internal/collector/generated/postgres_5s_metrics.json @@ -0,0 +1 @@ +[{"metrics":[{"attribute_columns":["application_name","datname","state","usename"],"description":"number of connections in this state","metric_name":"ccp_pg_stat_activity_count","static_attributes":{"server":"localhost:5432"},"value_column":"count"}],"sql":"SELECT\n pg_database.datname,\n tmp.state,\n COALESCE(tmp2.usename, '') as usename,\n COALESCE(tmp2.application_name, '') as application_name,\n COALESCE(count,0) as count,\n COALESCE(max_tx_duration,0) as max_tx_duration\nFROM\n (\n VALUES ('active'),\n ('idle'),\n ('idle in transaction'),\n ('idle in transaction (aborted)'),\n ('fastpath function call'),\n ('disabled')\n ) AS tmp(state) CROSS JOIN pg_database\nLEFT JOIN (\n SELECT\n datname,\n state,\n usename,\n application_name,\n count(*) AS count,\n MAX(EXTRACT(EPOCH FROM now() - xact_start))::float AS max_tx_duration\n FROM pg_stat_activity GROUP BY datname,state,usename,application_name) AS tmp2\n ON tmp.state = tmp2.state AND pg_database.datname = tmp2.datname;\n"},{"metrics":[{"description":"Seconds since the last successful archive operation","metric_name":"ccp_archive_command_status_seconds_since_last_archive","static_attributes":{"server":"localhost:5432"},"value_column":"seconds_since_last_archive","value_type":"double"},{"description":"Number of WAL files that have been successfully archived","metric_name":"ccp_archive_command_status_archived_count","static_attributes":{"server":"localhost:5432"},"value_column":"archived_count"},{"description":"Number of failed attempts for archiving WAL files","metric_name":"ccp_archive_command_status_failed_count","static_attributes":{"server":"localhost:5432"},"value_column":"failed_count"},{"description":"Seconds since the last recorded failure of the archive_command","metric_name":"ccp_archive_command_status_seconds_since_last_fail","static_attributes":{"server":"localhost:5432"},"value_column":"seconds_since_last_fail"}],"sql":"SELECT\n COALESCE(EXTRACT(epoch from (CURRENT_TIMESTAMP - last_archived_time)), 0) AS seconds_since_last_archive,\n archived_count,\n failed_count,\n CASE\n WHEN EXTRACT(epoch from (last_failed_time - last_archived_time)) IS NULL THEN 0\n WHEN EXTRACT(epoch from (last_failed_time - last_archived_time)) \u003c 0 THEN 0\n ELSE EXTRACT(epoch from (last_failed_time - last_archived_time))\n END AS seconds_since_last_fail\nFROM pg_catalog.pg_stat_archiver;\n"},{"metrics":[{"description":"Total non-idle connections","metric_name":"ccp_connection_stats_active","static_attributes":{"server":"localhost:5432"},"value_column":"active"},{"description":"Total idle connections","metric_name":"ccp_connection_stats_idle","static_attributes":{"server":"localhost:5432"},"value_column":"idle"},{"description":"Total idle in transaction connections","metric_name":"ccp_connection_stats_idle_in_txn","static_attributes":{"server":"localhost:5432"},"value_column":"idle_in_txn"},{"description":"Value of max_connections for the monitored database","metric_name":"ccp_connection_stats_max_blocked_query_time","static_attributes":{"server":"localhost:5432"},"value_column":"max_blocked_query_time","value_type":"double"},{"description":"Value of max_connections for the monitored database","metric_name":"ccp_connection_stats_max_connections","static_attributes":{"server":"localhost:5432"},"value_column":"max_connections"},{"description":"Length of time in seconds of the longest idle in transaction session","metric_name":"ccp_connection_stats_max_idle_in_txn_time","static_attributes":{"server":"localhost:5432"},"value_column":"max_idle_in_txn_time","value_type":"double"},{"description":"Length of time in seconds of the longest running query","metric_name":"ccp_connection_stats_max_query_time","static_attributes":{"server":"localhost:5432"},"value_column":"max_query_time","value_type":"double"},{"description":"Total idle and non-idle connections","metric_name":"ccp_connection_stats_total","static_attributes":{"server":"localhost:5432"},"value_column":"total"}],"sql":"SELECT ((total - idle) - idle_in_txn) as active\n , total\n , idle\n , idle_in_txn\n , (SELECT COALESCE(EXTRACT(epoch FROM (MAX(clock_timestamp() - state_change))),0) FROM pg_catalog.pg_stat_activity WHERE state = 'idle in transaction') AS max_idle_in_txn_time\n , (SELECT COALESCE(EXTRACT(epoch FROM (MAX(clock_timestamp() - query_start))),0) FROM pg_catalog.pg_stat_activity WHERE backend_type = 'client backend' AND state \u003c\u003e 'idle' ) AS max_query_time\n , (SELECT COALESCE(EXTRACT(epoch FROM (MAX(clock_timestamp() - query_start))),0) FROM pg_catalog.pg_stat_activity WHERE backend_type = 'client backend' AND wait_event_type = 'Lock' ) AS max_blocked_query_time\n , max_connections\n FROM (\n SELECT COUNT(*) as total\n , COALESCE(SUM(CASE WHEN state = 'idle' THEN 1 ELSE 0 END),0) AS idle\n , COALESCE(SUM(CASE WHEN state = 'idle in transaction' THEN 1 ELSE 0 END),0) AS idle_in_txn FROM pg_catalog.pg_stat_activity) x\n JOIN (SELECT setting::float AS max_connections FROM pg_settings WHERE name = 'max_connections') xx ON (true);\n"},{"metrics":[{"attribute_columns":["dbname"],"description":"Total number of checksum failures on this database","metric_name":"ccp_data_checksum_failure_count","static_attributes":{"server":"localhost:5432"},"value_column":"count"},{"attribute_columns":["dbname"],"description":"Time interval in seconds since the last checksum failure was encountered","metric_name":"ccp_data_checksum_failure_time_since_last_failure_seconds","static_attributes":{"server":"localhost:5432"},"value_column":"time_since_last_failure_seconds","value_type":"double"}],"sql":"SELECT datname AS dbname , checksum_failures AS count , coalesce(extract(epoch from (clock_timestamp() - checksum_last_failure)), 0) AS time_since_last_failure_seconds FROM pg_catalog.pg_stat_database WHERE pg_stat_database.datname IS NOT NULL;\n"},{"metrics":[{"attribute_columns":["dbname","mode"],"description":"Number of locks per mode type","metric_name":"ccp_locks_count","static_attributes":{"server":"localhost:5432"},"value_column":"count"}],"sql":"SELECT pg_database.datname as dbname , tmp.mode , COALESCE(count,0) as count FROM (\n VALUES ('accesssharelock'),\n ('rowsharelock'),\n ('rowexclusivelock'),\n ('shareupdateexclusivelock'),\n ('sharelock'),\n ('sharerowexclusivelock'),\n ('exclusivelock'),\n ('accessexclusivelock')\n) AS tmp(mode) CROSS JOIN pg_catalog.pg_database LEFT JOIN\n (SELECT database, lower(mode) AS mode,count(*) AS count\n FROM pg_catalog.pg_locks WHERE database IS NOT NULL\n GROUP BY database, lower(mode)\n) AS tmp2 ON tmp.mode=tmp2.mode and pg_database.oid = tmp2.database;\n"},{"metrics":[{"description":"CPU limit value in milli cores","metric_name":"ccp_nodemx_cpu_limit","static_attributes":{"server":"localhost:5432"},"value_column":"limit"},{"description":"CPU request value in milli cores","metric_name":"ccp_nodemx_cpu_request","static_attributes":{"server":"localhost:5432"},"value_column":"request"}],"sql":"SELECT monitor.kdapi_scalar_bigint('cpu_request') AS request , monitor.kdapi_scalar_bigint('cpu_limit') AS limit;\n"},{"metrics":[{"description":"CPU usage in nanoseconds","metric_name":"ccp_nodemx_cpuacct_usage","static_attributes":{"server":"localhost:5432"},"value_column":"usage","value_type":"double"},{"description":"CPU usage snapshot timestamp","metric_name":"ccp_nodemx_cpuacct_usage_ts","static_attributes":{"server":"localhost:5432"},"value_column":"usage_ts","value_type":"double"}],"sql":"SELECT CASE WHEN monitor.cgroup_mode() = 'legacy'\n THEN monitor.cgroup_scalar_bigint('cpuacct.usage')\n ELSE (SELECT val FROM monitor.cgroup_setof_kv('cpu.stat') where key = 'usage_usec') * 1000\n END AS usage,\n extract(epoch from clock_timestamp()) AS usage_ts;\n"},{"metrics":[{"description":"The total available run-time within a period (in microseconds)","metric_name":"ccp_nodemx_cpucfs_period_us","static_attributes":{"server":"localhost:5432"},"value_column":"period_us"},{"description":"The length of a period (in microseconds)","metric_name":"ccp_nodemx_cpucfs_quota_us","static_attributes":{"server":"localhost:5432"},"value_column":"quota_us","value_type":"double"}],"sql":"SELECT\n CASE\n WHEN monitor.cgroup_mode() = 'legacy' THEN\n monitor.cgroup_scalar_bigint('cpu.cfs_period_us')\n ELSE\n (monitor.cgroup_array_bigint('cpu.max'))[2]\n END AS period_us,\n CASE\n WHEN monitor.cgroup_mode() = 'legacy' THEN\n GREATEST(monitor.cgroup_scalar_bigint('cpu.cfs_quota_us'), 0)\n ELSE\n GREATEST((monitor.cgroup_array_bigint('cpu.max'))[1], 0)\n END AS quota_us;\n"},{"metrics":[{"description":"Number of periods that any thread was runnable","metric_name":"ccp_nodemx_cpustat_nr_periods","static_attributes":{"server":"localhost:5432"},"value_column":"nr_periods","value_type":"double"},{"description":"Number of runnable periods in which the application used its entire quota and was throttled","metric_name":"ccp_nodemx_cpustat_nr_throttled","static_attributes":{"server":"localhost:5432"},"value_column":"nr_throttled"},{"description":"CPU stat snapshot timestamp","metric_name":"ccp_nodemx_cpustat_snap_ts","static_attributes":{"server":"localhost:5432"},"value_column":"snap_ts","value_type":"double"},{"description":"Sum total amount of time individual threads within the monitor.cgroup were throttled","metric_name":"ccp_nodemx_cpustat_throttled_time","static_attributes":{"server":"localhost:5432"},"value_column":"throttled_time","value_type":"double"}],"sql":"WITH d(key, val) AS (select key, val from monitor.cgroup_setof_kv('cpu.stat')) SELECT\n (SELECT val FROM d WHERE key='nr_periods') AS nr_periods,\n (SELECT val FROM d WHERE key='nr_throttled') AS nr_throttled,\n (SELECT val FROM d WHERE key='throttled_usec') AS throttled_time,\n extract(epoch from clock_timestamp()) as snap_ts;\n"},{"metrics":[{"attribute_columns":["fs_type","mount_point"],"description":"Available size in bytes","metric_name":"ccp_nodemx_data_disk_available_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"available_bytes","value_type":"double"},{"attribute_columns":["fs_type","mount_point"],"description":"Available file nodes","metric_name":"ccp_nodemx_data_disk_free_file_nodes","static_attributes":{"server":"localhost:5432"},"value_column":"free_file_nodes"},{"attribute_columns":["fs_type","mount_point"],"description":"Size in bytes","metric_name":"ccp_nodemx_data_disk_total_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"total_bytes"},{"attribute_columns":["fs_type","mount_point"],"description":"Total file nodes","metric_name":"ccp_nodemx_data_disk_total_file_nodes","static_attributes":{"server":"localhost:5432"},"value_column":"total_file_nodes"}],"sql":"SELECT mount_point,fs_type,total_bytes,available_bytes,total_file_nodes,free_file_nodes\n FROM monitor.proc_mountinfo() m\n JOIN monitor.fsinfo(m.mount_point) f USING (major_number, minor_number)\n WHERE m.mount_point IN ('/pgdata', '/pgwal') OR\n m.mount_point like '/tablespaces/%';\n"},{"metrics":[{"attribute_columns":["mount_point"],"description":"Total sectors read","metric_name":"ccp_nodemx_disk_activity_sectors_read","static_attributes":{"server":"localhost:5432"},"value_column":"sectors_read"},{"attribute_columns":["mount_point"],"description":"Total sectors written","metric_name":"ccp_nodemx_disk_activity_sectors_written","static_attributes":{"server":"localhost:5432"},"value_column":"sectors_written"}],"sql":"SELECT mount_point,sectors_read,sectors_written\n FROM monitor.proc_mountinfo() m\n JOIN monitor.proc_diskstats() d USING (major_number, minor_number)\n WHERE m.mount_point IN ('/pgdata', '/pgwal') OR\n m.mount_point like '/tablespaces/%';\n"},{"metrics":[{"description":"Total bytes of anonymous and swap cache memory on active LRU list","metric_name":"ccp_nodemx_mem_active_anon","static_attributes":{"server":"localhost:5432"},"value_column":"active_anon","value_type":"double"},{"description":"Total bytes of file-backed memory on active LRU list","metric_name":"ccp_nodemx_mem_active_file","static_attributes":{"server":"localhost:5432"},"value_column":"active_file","value_type":"double"},{"description":"Total bytes of page cache memory","metric_name":"ccp_nodemx_mem_cache","static_attributes":{"server":"localhost:5432"},"value_column":"cache","value_type":"double"},{"description":"Total bytes that are waiting to get written back to the disk","metric_name":"ccp_nodemx_mem_dirty","static_attributes":{"server":"localhost:5432"},"value_column":"dirty"},{"description":"Total bytes of anonymous and swap cache memory on inactive LRU list","metric_name":"ccp_nodemx_mem_inactive_anon","static_attributes":{"server":"localhost:5432"},"value_column":"inactive_anon","value_type":"double"},{"description":"Total bytes of file-backed memory on inactive LRU list","metric_name":"ccp_nodemx_mem_inactive_file","static_attributes":{"server":"localhost:5432"},"value_column":"inactive_file","value_type":"double"},{"description":"Unknown metric from ccp_nodemx_mem","metric_name":"ccp_nodemx_mem_kmem_usage_in_byte","static_attributes":{"server":"localhost:5432"},"value_column":"kmem_usage_in_byte"},{"description":"Memory limit value in bytes","metric_name":"ccp_nodemx_mem_limit","static_attributes":{"server":"localhost:5432"},"value_column":"limit"},{"description":"Total bytes of mapped file (includes tmpfs/shmem)","metric_name":"ccp_nodemx_mem_mapped_file","static_attributes":{"server":"localhost:5432"},"value_column":"mapped_file"},{"description":"Memory request value in bytes","metric_name":"ccp_nodemx_mem_request","static_attributes":{"server":"localhost:5432"},"value_column":"request"},{"description":"Total bytes of anonymous and swap cache memory","metric_name":"ccp_nodemx_mem_rss","static_attributes":{"server":"localhost:5432"},"value_column":"rss","value_type":"double"},{"description":"Total bytes of shared memory","metric_name":"ccp_nodemx_mem_shmem","static_attributes":{"server":"localhost:5432"},"value_column":"shmem","value_type":"double"},{"description":"Total usage in bytes","metric_name":"ccp_nodemx_mem_usage_in_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"usage_in_bytes"}],"sql":"WITH d(key, val) as (SELECT key, val FROM monitor.cgroup_setof_kv('memory.stat')) SELECT\n monitor.kdapi_scalar_bigint('mem_request') AS request,\n CASE\n WHEN monitor.cgroup_mode() = 'legacy' THEN\n (CASE WHEN monitor.cgroup_scalar_bigint('memory.limit_in_bytes') = 9223372036854771712 THEN 0 ELSE monitor.cgroup_scalar_bigint('memory.limit_in_bytes') END)\n ELSE\n (CASE WHEN monitor.cgroup_scalar_bigint('memory.max') = 9223372036854775807 THEN 0 ELSE monitor.cgroup_scalar_bigint('memory.max') END)\n END AS limit,\n CASE\n WHEN monitor.cgroup_mode() = 'legacy'\n THEN (SELECT val FROM d WHERE key='cache')\n ELSE 0\n END as cache,\n CASE\n WHEN monitor.cgroup_mode() = 'legacy'\n THEN (SELECT val FROM d WHERE key='rss')\n ELSE 0\n END as RSS,\n (SELECT val FROM d WHERE key='shmem') as shmem,\n CASE\n WHEN monitor.cgroup_mode() = 'legacy'\n THEN (SELECT val FROM d WHERE key='mapped_file')\n ELSE 0\n END as mapped_file,\n CASE\n WHEN monitor.cgroup_mode() = 'legacy'\n THEN (SELECT val FROM d WHERE key='dirty')\n ELSE (SELECT val FROM d WHERE key='file_dirty')\n END as dirty,\n (SELECT val FROM d WHERE key='active_anon') as active_anon,\n (SELECT val FROM d WHERE key='inactive_anon') as inactive_anon,\n (SELECT val FROM d WHERE key='active_file') as active_file,\n (SELECT val FROM d WHERE key='inactive_file') as inactive_file,\n CASE\n WHEN monitor.cgroup_mode() = 'legacy'\n THEN monitor.cgroup_scalar_bigint('memory.usage_in_bytes')\n ELSE monitor.cgroup_scalar_bigint('memory.current')\n END as usage_in_bytes,\n CASE\n WHEN monitor.cgroup_mode() = 'legacy'\n THEN monitor.cgroup_scalar_bigint('memory.kmem.usage_in_bytes')\n ELSE 0\n END as kmem_usage_in_byte;\n"},{"metrics":[{"attribute_columns":["interface"],"description":"Number of bytes received","metric_name":"ccp_nodemx_network_rx_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"rx_bytes"},{"attribute_columns":["interface"],"description":"Number of packets received","metric_name":"ccp_nodemx_network_rx_packets","static_attributes":{"server":"localhost:5432"},"value_column":"rx_packets"},{"attribute_columns":["interface"],"description":"Number of bytes transmitted","metric_name":"ccp_nodemx_network_tx_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"tx_bytes"},{"attribute_columns":["interface"],"description":"Number of packets transmitted","metric_name":"ccp_nodemx_network_tx_packets","static_attributes":{"server":"localhost:5432"},"value_column":"tx_packets"}],"sql":"SELECT interface\n ,tx_bytes\n ,tx_packets\n ,rx_bytes\n ,rx_packets from monitor.proc_network_stats();\n"},{"metrics":[{"description":"Total number of database processes","metric_name":"ccp_nodemx_process_count","static_attributes":{"server":"localhost:5432"},"value_column":"count"}],"sql":"SELECT monitor.cgroup_process_count() as count;\n"},{"metrics":[{"description":"Epoch time when stats were reset","metric_name":"ccp_pg_stat_statements_reset_time","static_attributes":{"server":"localhost:5432"},"value_column":"time"}],"sql":"SELECT monitor.pg_stat_statements_reset_info(-1) as time;\n"},{"metrics":[{"attribute_columns":["dbname","query","queryid","role"],"description":"Average query runtime in milliseconds","metric_name":"ccp_pg_stat_statements_top_mean_exec_time_ms","static_attributes":{"server":"localhost:5432"},"value_column":"top_mean_exec_time_ms","value_type":"double"}],"sql":"WITH monitor AS (\n SELECT\n pg_get_userbyid(s.userid) AS role\n , d.datname AS dbname\n , s.queryid AS queryid\n , btrim(replace(left(s.query, 40), '\\n', '')) AS query\n , s.calls\n , s.total_exec_time AS total_exec_time\n , s.max_exec_time AS max_exec_time\n , s.mean_exec_time AS mean_exec_time\n , s.rows\n , s.wal_records AS records\n , s.wal_fpi AS fpi\n , s.wal_bytes AS bytes\n FROM public.pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n) SELECT role\n , dbname\n , queryid\n , query\n , max(monitor.mean_exec_time) AS top_mean_exec_time_ms\nFROM monitor GROUP BY 1,2,3,4 ORDER BY 5 DESC LIMIT 20;\n"},{"metrics":[{"attribute_columns":["dbname","role"],"description":"Total number of queries run per user/database","metric_name":"ccp_pg_stat_statements_total_calls_count","static_attributes":{"server":"localhost:5432"},"value_column":"calls_count","value_type":"double"},{"attribute_columns":["dbname","role"],"description":"Total runtime of all queries per user/database","metric_name":"ccp_pg_stat_statements_total_exec_time_ms","static_attributes":{"server":"localhost:5432"},"value_column":"exec_time_ms","value_type":"double"},{"attribute_columns":["dbname","role"],"description":"Total runtime of all queries per user/database","metric_name":"ccp_pg_stat_statements_total_mean_exec_time_ms","static_attributes":{"server":"localhost:5432"},"value_column":"mean_exec_time_ms","value_type":"double"},{"attribute_columns":["dbname","role"],"description":"Total rows returned from all queries per user/database","metric_name":"ccp_pg_stat_statements_total_row_count","static_attributes":{"server":"localhost:5432"},"value_column":"row_count","value_type":"double"}],"sql":"WITH monitor AS (\n SELECT\n pg_get_userbyid(s.userid) AS role\n , d.datname AS dbname\n , s.calls\n , s.total_exec_time\n , s.mean_exec_time\n , s.rows\n FROM public.pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n) SELECT role\n , dbname\n , sum(calls) AS calls_count\n , sum(total_exec_time) AS exec_time_ms\n , avg(mean_exec_time) AS mean_exec_time_ms\n , sum(rows) AS row_count\nFROM monitor GROUP BY 1,2;\n"},{"metrics":[{"description":"The current version of PostgreSQL that this exporter is running on as a 6 digit integer (######).","metric_name":"ccp_postgresql_version_current","static_attributes":{"server":"localhost:5432"},"value_column":"current"}],"sql":"SELECT current_setting('server_version_num')::int AS current;\n"},{"metrics":[{"description":"Time interval in seconds since PostgreSQL database was last restarted.","metric_name":"ccp_postmaster_uptime_seconds","static_attributes":{"server":"localhost:5432"},"value_column":"seconds","value_type":"double"}],"sql":"SELECT extract(epoch from (clock_timestamp() - pg_postmaster_start_time() )) AS seconds;\n"},{"metrics":[{"attribute_columns":["replica"],"description":"Replication lag in bytes.","metric_name":"ccp_replication_lag_size_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"bytes","value_type":"double"}],"sql":"SELECT * FROM get_replication_lag();\n"},{"metrics":[{"description":"Return value of 1 means database is in recovery. Otherwise 2 it is a primary","metric_name":"ccp_is_in_recovery_status","static_attributes":{"server":"localhost:5432"},"value_column":"status","value_type":"double"},{"attribute_columns":["role"],"description":"Length of time since the last WAL file was received and replayed on replica.\nAlways increases, possibly causing false positives if the primary stops writing.\nMonitors for replicas that stop receiving WAL all together.\n","metric_name":"ccp_replication_lag_received_time","static_attributes":{"server":"localhost:5432"},"value_column":"received_time","value_type":"double"},{"attribute_columns":["role"],"description":"Length of time since the last transaction was replayed on replica.\nReturns zero if last WAL received equals last WAL replayed. Avoids\nfalse positives when primary stops writing. Monitors for replicas that\ncannot keep up with primary WAL generation.\n","metric_name":"ccp_replication_lag_replay_time","static_attributes":{"server":"localhost:5432"},"value_column":"replay_time","value_type":"double"}],"sql":"SELECT\n COALESCE(\n CASE\n WHEN (pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()) OR (pg_is_in_recovery() = false) THEN 0\n ELSE EXTRACT (EPOCH FROM clock_timestamp() - pg_last_xact_replay_timestamp())::INTEGER\n END,\n 0\n ) AS replay_time,\n COALESCE(\n CASE\n WHEN pg_is_in_recovery() = false THEN 0\n ELSE EXTRACT (EPOCH FROM clock_timestamp() - pg_last_xact_replay_timestamp())::INTEGER\n END,\n 0\n ) AS received_time,\n CASE\n WHEN pg_is_in_recovery() = true THEN 'replica'\n ELSE 'primary'\n END AS role,\n CASE\n WHEN pg_is_in_recovery() = true THEN 1\n ELSE 2\n END AS status;\n"},{"metrics":[{"description":"Number of settings from pg_settings catalog in a pending_restart state","metric_name":"ccp_settings_pending_restart_count","static_attributes":{"server":"localhost:5432"},"value_column":"count"}],"sql":"SELECT count(*) AS count FROM pg_catalog.pg_settings WHERE pending_restart = true;\n"},{"metrics":[{"description":"Number of buffers allocated","metric_name":"ccp_stat_bgwriter_buffers_alloc","static_attributes":{"server":"localhost:5432"},"value_column":"buffers_alloc"},{"data_type":"sum","description":"Number of buffers written by the background writer","metric_name":"ccp_stat_bgwriter_buffers_clean","static_attributes":{"server":"localhost:5432"},"value_column":"buffers_clean"},{"description":"Number of times the background writer stopped a cleaning scan because it had written too many buffers","metric_name":"ccp_stat_bgwriter_maxwritten_clean","static_attributes":{"server":"localhost:5432"},"value_column":"maxwritten_clean"}],"sql":"SELECT\n buffers_clean\n , maxwritten_clean\n , buffers_alloc\nFROM pg_catalog.pg_stat_bgwriter;\n"},{"metrics":[{"description":"Oldest current transaction ID in cluster","metric_name":"ccp_transaction_wraparound_oldest_current_xid","static_attributes":{"server":"localhost:5432"},"value_column":"oldest_current_xid"},{"description":"Percentage towards emergency autovacuum process starting","metric_name":"ccp_transaction_wraparound_percent_towards_emergency_autovac","static_attributes":{"server":"localhost:5432"},"value_column":"percent_towards_emergency_autovac"},{"description":"Percentage towards transaction ID wraparound","metric_name":"ccp_transaction_wraparound_percent_towards_wraparound","static_attributes":{"server":"localhost:5432"},"value_column":"percent_towards_wraparound"}],"sql":"WITH max_age AS (\n SELECT 2000000000 as max_old_xid\n , setting AS autovacuum_freeze_max_age\n FROM pg_catalog.pg_settings\n WHERE name = 'autovacuum_freeze_max_age')\n, per_database_stats AS (\n SELECT datname\n , m.max_old_xid::int\n , m.autovacuum_freeze_max_age::int\n , age(d.datfrozenxid) AS oldest_current_xid\n FROM pg_catalog.pg_database d\n JOIN max_age m ON (true)\n WHERE d.datallowconn)\nSELECT max(oldest_current_xid) AS oldest_current_xid , max(ROUND(100*(oldest_current_xid/max_old_xid::float))) AS percent_towards_wraparound , max(ROUND(100*(oldest_current_xid/autovacuum_freeze_max_age::float))) AS percent_towards_emergency_autovac FROM per_database_stats;\n"},{"metrics":[{"description":"Current size in bytes of the WAL directory","metric_name":"ccp_wal_activity_total_size_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"total_size_bytes"}],"sql":"SELECT last_5_min_size_bytes,\n (SELECT COALESCE(sum(size),0) FROM pg_catalog.pg_ls_waldir()) AS total_size_bytes\n FROM (SELECT COALESCE(sum(size),0) AS last_5_min_size_bytes FROM pg_catalog.pg_ls_waldir() WHERE modification \u003e CURRENT_TIMESTAMP - '5 minutes'::interval) x;\n"},{"metrics":[{"attribute_columns":["dbname","query","queryid","role"],"description":"Epoch time when stats were reset","metric_name":"ccp_pg_stat_statements_top_max_exec_time_ms","static_attributes":{"server":"localhost:5432"},"value_column":"max_exec_time_ms","value_type":"double"}],"sql":"WITH monitor AS (\n SELECT\n pg_get_userbyid(s.userid) AS role\n , d.datname AS dbname\n , s.queryid AS queryid\n , btrim(replace(left(s.query, 40), '\\n', '')) AS query\n , s.calls\n , s.total_exec_time AS total_exec_time\n , s.max_exec_time AS max_exec_time_ms\n , s.rows\n , s.wal_records AS records\n , s.wal_fpi AS fpi\n , s.wal_bytes AS bytes\n FROM public.pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n) SELECT role\n , dbname\n , queryid\n , query\n , max_exec_time_ms\n , records\nFROM monitor ORDER BY 5 DESC LIMIT 20;\n"},{"metrics":[{"attribute_columns":["dbname","query","queryid","role"],"description":"Total time spent in the statement in milliseconds","metric_name":"ccp_pg_stat_statements_top_total_exec_time_ms","static_attributes":{"server":"localhost:5432"},"value_column":"total_exec_time_ms","value_type":"double"}],"sql":"WITH monitor AS (\n SELECT\n pg_get_userbyid(s.userid) AS role\n , d.datname AS dbname\n , s.queryid AS queryid\n , btrim(replace(left(s.query, 40), '\\n', '')) AS query\n , s.calls\n , s.total_exec_time AS total_exec_time_ms\n , s.rows\n , s.wal_records AS records\n , s.wal_fpi AS fpi\n , s.wal_bytes AS bytes\n FROM public.pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n) SELECT role\n , dbname\n , queryid\n , query\n , total_exec_time_ms\n , records\nFROM monitor ORDER BY 5 DESC LIMIT 20;\n"},{"metrics":[{"attribute_columns":["dbname","query","queryid","role"],"description":"Total amount of WAL generated by the statement in bytes","metric_name":"ccp_pg_stat_statements_top_wal_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"bytes","value_type":"double"},{"attribute_columns":["dbname","query","queryid","role"],"description":"Total number of WAL full page images generated by the statement","metric_name":"ccp_pg_stat_statements_top_wal_fpi","static_attributes":{"server":"localhost:5432"},"value_column":"fpi","value_type":"double"},{"attribute_columns":["dbname","query","queryid","role"],"description":"Total number of WAL records generated by the statement","metric_name":"ccp_pg_stat_statements_top_wal_records","static_attributes":{"server":"localhost:5432"},"value_column":"records","value_type":"double"}],"sql":"WITH monitor AS (\n SELECT\n pg_get_userbyid(s.userid) AS role\n , d.datname AS dbname\n , s.queryid AS queryid\n , btrim(replace(left(s.query, 40), '\\n', '')) AS query\n , s.calls\n , s.total_exec_time AS total_exec_time\n , s.max_exec_time AS max_exec_time\n , s.mean_exec_time AS mean_exec_time\n , s.rows\n , s.wal_records AS records\n , s.wal_fpi AS fpi\n , s.wal_bytes AS bytes\n FROM public.pg_stat_statements s\n JOIN pg_catalog.pg_database d ON d.oid = s.dbid\n) SELECT role\n , dbname\n , query\n , queryid\n , records\n , fpi\n , bytes\nFROM monitor ORDER BY bytes DESC LIMIT 20;\n"},{"metrics":[{"attribute_columns":["repo"],"description":"Seconds since the last completed full or differential backup. Differential is always based off last full.","metric_name":"ccp_backrest_last_diff_backup_time_since_completion_seconds","static_attributes":{"server":"localhost:5432","stanza":"db"},"value_column":"last_diff_backup"},{"attribute_columns":["repo"],"description":"Seconds since the last completed full backup","metric_name":"ccp_backrest_last_full_backup_time_since_completion_seconds","static_attributes":{"server":"localhost:5432","stanza":"db"},"value_column":"last_full_backup"},{"attribute_columns":["repo"],"description":"Seconds since the last completed full, differential or incremental backup.\nIncremental is always based off last full or differential.\n","metric_name":"ccp_backrest_last_incr_backup_time_since_completion_seconds","static_attributes":{"server":"localhost:5432","stanza":"db"},"value_column":"last_incr_backup"},{"attribute_columns":["backup_type","repo"],"description":"pgBackRest version number when this backup was performed","metric_name":"ccp_backrest_last_info_backrest_repo_version","static_attributes":{"server":"localhost:5432","stanza":"db"},"value_column":"last_info_backrest_repo_version"},{"attribute_columns":["backup_type","repo"],"description":"An error has been encountered in the backup. Check logs for more information.","metric_name":"ccp_backrest_last_info_backup_error","static_attributes":{"server":"localhost:5432","stanza":"db"},"value_column":"last_info_backup_error"},{"attribute_columns":["backup_type","repo"],"description":"Total runtime in seconds of this backup","metric_name":"ccp_backrest_last_info_backup_runtime_seconds","static_attributes":{"server":"localhost:5432","stanza":"db"},"value_column":"backup_runtime_seconds"},{"attribute_columns":["backup_type","repo"],"description":"Actual size of only this individual backup in the pgbackrest repository","metric_name":"ccp_backrest_last_info_repo_backup_size_bytes","static_attributes":{"server":"localhost:5432","stanza":"db"},"value_column":"repo_backup_size_bytes"},{"attribute_columns":["repo"],"description":"Seconds since the oldest completed full backup","metric_name":"ccp_backrest_oldest_full_backup_time_seconds","static_attributes":{"server":"localhost:5432"},"value_column":"oldest_full_backup"}],"sql":"SELECT * FROM get_pgbackrest_info();\n"},{"metrics":[{"attribute_columns":["dbname"],"description":"Number of times disk blocks were found already in the buffer cache, so that a read was not necessary","metric_name":"ccp_stat_database_blks_hit","static_attributes":{"server":"localhost:5432"},"value_column":"blks_hit"},{"attribute_columns":["dbname"],"description":"Number of disk blocks read in this database","metric_name":"ccp_stat_database_blks_read","static_attributes":{"server":"localhost:5432"},"value_column":"blks_read"},{"attribute_columns":["dbname"],"description":"Number of queries canceled due to conflicts with recovery in this database","metric_name":"ccp_stat_database_conflicts","static_attributes":{"server":"localhost:5432"},"value_column":"conflicts"},{"attribute_columns":["dbname"],"description":"Number of deadlocks detected in this database","metric_name":"ccp_stat_database_deadlocks","static_attributes":{"server":"localhost:5432"},"value_column":"deadlocks"},{"attribute_columns":["dbname"],"description":"Total amount of data written to temporary files by queries in this database","metric_name":"ccp_stat_database_temp_bytes","static_attributes":{"server":"localhost:5432"},"value_column":"temp_bytes"},{"attribute_columns":["dbname"],"description":"Number of rows deleted by queries in this database","metric_name":"ccp_stat_database_temp_files","static_attributes":{"server":"localhost:5432"},"value_column":"temp_files"},{"attribute_columns":["dbname"],"description":"Number of rows deleted by queries in this database","metric_name":"ccp_stat_database_tup_deleted","static_attributes":{"server":"localhost:5432"},"value_column":"tup_deleted"},{"attribute_columns":["dbname"],"description":"Number of rows fetched by queries in this database","metric_name":"ccp_stat_database_tup_fetched","static_attributes":{"server":"localhost:5432"},"value_column":"tup_fetched"},{"attribute_columns":["dbname"],"description":"Number of rows inserted by queries in this database","metric_name":"ccp_stat_database_tup_inserted","static_attributes":{"server":"localhost:5432"},"value_column":"tup_inserted"},{"attribute_columns":["dbname"],"description":"Number of rows returned by queries in this database","metric_name":"ccp_stat_database_tup_returned","static_attributes":{"server":"localhost:5432"},"value_column":"tup_returned"},{"attribute_columns":["dbname"],"description":"Number of rows updated by queries in this database","metric_name":"ccp_stat_database_tup_updated","static_attributes":{"server":"localhost:5432"},"value_column":"tup_updated"},{"attribute_columns":["dbname"],"description":"Number of transactions in this database that have been committed","metric_name":"ccp_stat_database_xact_commit","static_attributes":{"server":"localhost:5432"},"value_column":"xact_commit"},{"attribute_columns":["dbname"],"description":"Number of transactions in this database that have been rolled back","metric_name":"ccp_stat_database_xact_rollback","static_attributes":{"server":"localhost:5432"},"value_column":"xact_rollback"}],"sql":"SELECT s.datname AS dbname , s.xact_commit , s.xact_rollback , s.blks_read , s.blks_hit , s.tup_returned , s.tup_fetched , s.tup_inserted , s.tup_updated , s.tup_deleted , s.conflicts , s.temp_files , s.temp_bytes , s.deadlocks FROM pg_catalog.pg_stat_database s JOIN pg_catalog.pg_database d ON d.datname = s.datname WHERE d.datistemplate = false;\n"}] diff --git a/internal/collector/generated/postgres_logs_transforms.json b/internal/collector/generated/postgres_logs_transforms.json new file mode 100644 index 0000000000..066c067399 --- /dev/null +++ b/internal/collector/generated/postgres_logs_transforms.json @@ -0,0 +1 @@ +[{"conditions":["log.body[\"format\"] == \"csv\""],"statements":["set(log.cache, ParseCSV(log.body[\"original\"], log.body[\"headers\"], delimiter=\",\", mode=\"strict\"))","merge_maps(log.cache, ExtractPatterns(log.cache[\"connection_from\"], \"(?:^[[]local[]]:(?\u003cremote_port\u003e.+)|:(?\u003cremote_port\u003e[^:]+))$\"), \"insert\") where Len(log.cache[\"connection_from\"]) \u003e 0","set(log.cache[\"remote_host\"], Substring(log.cache[\"connection_from\"], 0, Len(log.cache[\"connection_from\"]) - Len(log.cache[\"remote_port\"]) - 1)) where Len(log.cache[\"connection_from\"]) \u003e 0 and IsString(log.cache[\"remote_port\"])","set(log.cache[\"remote_host\"], log.cache[\"connection_from\"]) where Len(log.cache[\"connection_from\"]) \u003e 0 and not IsString(log.cache[\"remote_host\"])","merge_maps(log.cache, ExtractPatterns(log.cache[\"location\"], \"^(?:(?\u003cfunc_name\u003e[^,]+), )?(?\u003cfile_name\u003e[^:]+):(?\u003cfile_line_num\u003e\\\\d+)$\"), \"insert\") where Len(log.cache[\"location\"]) \u003e 0","set(log.cache[\"cursor_position\"], Double(log.cache[\"cursor_position\"])) where IsMatch(log.cache[\"cursor_position\"], \"^[0-9.]+$\")","set(log.cache[\"file_line_num\"], Double(log.cache[\"file_line_num\"])) where IsMatch(log.cache[\"file_line_num\"], \"^[0-9.]+$\")","set(log.cache[\"internal_position\"], Double(log.cache[\"internal_position\"])) where IsMatch(log.cache[\"internal_position\"], \"^[0-9.]+$\")","set(log.cache[\"leader_pid\"], Double(log.cache[\"leader_pid\"])) where IsMatch(log.cache[\"leader_pid\"], \"^[0-9.]+$\")","set(log.cache[\"line_num\"], Double(log.cache[\"line_num\"])) where IsMatch(log.cache[\"line_num\"], \"^[0-9.]+$\")","set(log.cache[\"pid\"], Double(log.cache[\"pid\"])) where IsMatch(log.cache[\"pid\"], \"^[0-9.]+$\")","set(log.cache[\"query_id\"], Double(log.cache[\"query_id\"])) where IsMatch(log.cache[\"query_id\"], \"^[0-9.]+$\")","set(log.cache[\"remote_port\"], Double(log.cache[\"remote_port\"])) where IsMatch(log.cache[\"remote_port\"], \"^[0-9.]+$\")","set(log.body[\"parsed\"], log.cache)"]},{"statements":["set(instrumentation_scope.name, \"postgres\")","set(instrumentation_scope.version, resource.attributes[\"db.version\"])","set(log.cache, log.body[\"parsed\"]) where log.body[\"format\"] == \"csv\"","set(log.cache, ParseJSON(log.body[\"original\"])) where log.body[\"format\"] == \"json\"","set(log.severity_text, log.cache[\"error_severity\"])","set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == \"DEBUG5\"","set(log.severity_number, SEVERITY_NUMBER_TRACE2) where log.severity_text == \"DEBUG4\"","set(log.severity_number, SEVERITY_NUMBER_TRACE3) where log.severity_text == \"DEBUG3\"","set(log.severity_number, SEVERITY_NUMBER_TRACE4) where log.severity_text == \"DEBUG2\"","set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == \"DEBUG1\"","set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == \"INFO\" or log.severity_text == \"LOG\"","set(log.severity_number, SEVERITY_NUMBER_INFO2) where log.severity_text == \"NOTICE\"","set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == \"WARNING\"","set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == \"ERROR\"","set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == \"FATAL\"","set(log.severity_number, SEVERITY_NUMBER_FATAL2) where log.severity_text == \"PANIC\"","set(log.time, Time(log.cache[\"timestamp\"], \"%F %T.%L %Z\")) where IsString(log.cache[\"timestamp\"])","set(instrumentation_scope.schema_url, \"https://opentelemetry.io/schemas/1.29.0\")","set(resource.attributes[\"db.system\"], \"postgresql\")","set(log.attributes[\"log.record.original\"], log.body[\"original\"])","set(log.body, log.cache)","set(log.attributes[\"client.address\"], log.body[\"remote_host\"]) where IsString(log.body[\"remote_host\"])","set(log.attributes[\"client.port\"], Int(log.body[\"remote_port\"])) where IsDouble(log.body[\"remote_port\"])","set(log.attributes[\"code.filepath\"], log.body[\"file_name\"]) where IsString(log.body[\"file_name\"])","set(log.attributes[\"code.function\"], log.body[\"func_name\"]) where IsString(log.body[\"func_name\"])","set(log.attributes[\"code.lineno\"], Int(log.body[\"file_line_num\"])) where IsDouble(log.body[\"file_line_num\"])","set(log.attributes[\"db.namespace\"], log.body[\"dbname\"]) where IsString(log.body[\"dbname\"])","set(log.attributes[\"db.response.status_code\"], log.body[\"state_code\"]) where IsString(log.body[\"state_code\"])","set(log.attributes[\"process.creation.time\"], Concat([ Substring(log.body[\"session_start\"], 0, 10), \"T\", Substring(log.body[\"session_start\"], 11, 8), \"Z\"], \"\")) where IsMatch(log.body[\"session_start\"], \"^[^ ]{10} [^ ]{8} UTC$\")","set(log.attributes[\"process.pid\"], Int(log.body[\"pid\"])) where IsDouble(log.body[\"pid\"])","set(log.attributes[\"process.title\"], log.body[\"ps\"]) where IsString(log.body[\"ps\"])","set(log.attributes[\"user.name\"], log.body[\"user\"]) where IsString(log.body[\"user\"])"]},{"conditions":["Len(log.body[\"message\"]) \u003e 7 and Substring(log.body[\"message\"], 0, 7) == \"AUDIT: \""],"statements":["set(log.body[\"pgaudit\"], ParseCSV(Substring(log.body[\"message\"], 7, Len(log.body[\"message\"]) - 7), \"audit_type,statement_id,substatement_id,class,command,object_type,object_name,statement,parameter\", delimiter=\",\", mode=\"strict\"))","set(instrumentation_scope.name, \"pgaudit\") where Len(log.body[\"pgaudit\"]) \u003e 0"]}] diff --git a/internal/collector/gte_pg17_fast_metrics.yaml b/internal/collector/gte_pg17_fast_metrics.yaml new file mode 100644 index 0000000000..a590b48272 --- /dev/null +++ b/internal/collector/gte_pg17_fast_metrics.yaml @@ -0,0 +1,107 @@ +# This list of queries configures an OTel SQL Query Receiver to read pgMonitor +# metrics from Postgres. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/sqlqueryreceiver#metrics-queries +# https://github.com/CrunchyData/pgmonitor/blob/development/sql_exporter/common/crunchy_global_collector.yml + + - sql: > + SELECT + s.writes + , s.fsyncs + FROM pg_catalog.pg_stat_io s + WHERE backend_type = 'background writer'; + metrics: + - metric_name: ccp_stat_io_bgwriter_writes + value_column: writes + data_type: sum + description: Number of write operations by background writers + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_io_bgwriter_fsyncs + value_column: fsyncs + data_type: sum + description: Number of fsync calls by background writers + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT + c.num_timed + , c.num_requested + , c.write_time + , c.sync_time + , c.buffers_written + FROM pg_catalog.pg_stat_checkpointer c; + metrics: + - metric_name: ccp_stat_checkpointer_num_timed + value_column: num_timed + description: Number of scheduled checkpoints that have been performed + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_checkpointer_num_requested + value_column: num_requested + description: Number of requested checkpoints that have been performed + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_checkpointer_write_time + value_column: write_time + value_type: double + description: Total amount of time that has been spent in the portion of checkpoint processing where files are written to disk, in milliseconds + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_checkpointer_sync_time + value_column: sync_time + description: Total amount of time that has been spent in the portion of checkpoint processing where files are synchronized to disk, in milliseconds + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_checkpointer_buffers_written + description: Number of buffers written during checkpoints and restartpoints + value_column: buffers_written + static_attributes: + server: "localhost:5432" + +# NOTE: Some of the columns below can return NULL values, for which sqlqueryreceiver will warn. +# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/sqlqueryreceiver#null-values +# Those columns are retained_bytes, database, conflicting, failover, and synced and we avoid NULL by using COALESCE. + - sql: > + SELECT + s.slot_name + , s.active::int + , COALESCE(pg_wal_lsn_diff(CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_insert_lsn() END, s.restart_lsn), 0) AS retained_bytes + , COALESCE(s.database, '') + , s.slot_type + , COALESCE(s.conflicting::int, 0) + , COALESCE(s.failover::int, 0) + , COALESCE(s.synced::int, 0) + FROM pg_catalog.pg_replication_slots s; + metrics: + - metric_name: ccp_replication_slots_active + value_column: active + description: Active state of slot. 1 = true. 0 = false. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_retained_bytes + value_column: retained_bytes + description: The amount of WAL (in bytes) being retained for this slot + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_conflicting + value_column: conflicting + description: True if this logical slot conflicted with recovery (and so is now invalidated). When this column is true, check invalidation_reason column for the conflict reason. Always NULL for physical slots. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_failover + value_column: failover + description: True if this is a logical slot enabled to be synced to the standbys so that logical replication can be resumed from the new primary after failover. Always false for physical slots. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_synced + value_column: synced + description: True if this is a logical slot that was synced from a primary server. On a hot standby, the slots with the synced column marked as true can neither be used for logical decoding nor dropped manually. The value of this column has no meaning on the primary server; the column value on the primary is default false for all slots but may (if leftover from a promoted standby) also be true. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" diff --git a/internal/collector/helpers_test.go b/internal/collector/helpers_test.go new file mode 100644 index 0000000000..1f174ebcda --- /dev/null +++ b/internal/collector/helpers_test.go @@ -0,0 +1,32 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func testInstrumentationSpec() *v1beta1.InstrumentationSpec { + spec := v1beta1.InstrumentationSpec{ + Config: &v1beta1.InstrumentationConfigSpec{ + Exporters: map[string]any{ + "googlecloud": map[string]any{ + "log": map[string]any{ + "default_log_name": "opentelemetry.io/collector-exported-log", + }, + "project": "google-project-name", + }, + }, + }, + Logs: &v1beta1.InstrumentationLogsSpec{ + Exporters: []string{"googlecloud"}, + }, + Metrics: &v1beta1.InstrumentationMetricsSpec{ + Exporters: []string{"googlecloud"}, + }, + } + + return spec.DeepCopy() +} diff --git a/internal/collector/instance.go b/internal/collector/instance.go new file mode 100644 index 0000000000..9cb1708042 --- /dev/null +++ b/internal/collector/instance.go @@ -0,0 +1,227 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + "fmt" + "path" + + corev1 "k8s.io/api/core/v1" + + "github.com/crunchydata/postgres-operator/internal/config" + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/shell" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +const configDirectory = "/etc/otel-collector" + +// AddToConfigMap populates the shared ConfigMap with fields needed to run the Collector. +func AddToConfigMap( + ctx context.Context, + inConfig *Config, + outInstanceConfigMap *corev1.ConfigMap, +) error { + var err error + if outInstanceConfigMap.Data == nil { + outInstanceConfigMap.Data = make(map[string]string) + } + + outInstanceConfigMap.Data["collector.yaml"], err = inConfig.ToYAML() + + return err +} + +// AddToPod adds the OpenTelemetry collector container to a given Pod +func AddToPod( + ctx context.Context, + spec *v1beta1.InstrumentationSpec, + pullPolicy corev1.PullPolicy, + inInstanceConfigMap *corev1.ConfigMap, + template *corev1.PodTemplateSpec, + volumeMounts []corev1.VolumeMount, + sqlQueryPassword string, + logDirectories []string, + includeLogrotate bool, + thisPodServesMetrics bool, +) { + if !OpenTelemetryLogsOrMetricsEnabled(ctx, spec) { + return + } + + // We only want to include log rotation if this type of pod requires it + // (indicate by the includeLogrotate boolean) AND if logging is enabled + // for this PostgresCluster/PGAdmin + includeLogrotate = includeLogrotate && OpenTelemetryLogsEnabled(ctx, spec) + + // Create volume and volume mount for otel collector config + configVolumeMount := corev1.VolumeMount{ + Name: "collector-config", + MountPath: configDirectory, + ReadOnly: true, + } + configVolume := corev1.Volume{Name: configVolumeMount.Name} + configVolume.Projected = &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{{ + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: inInstanceConfigMap.Name, + }, + Items: []corev1.KeyToPath{{ + Key: "collector.yaml", + Path: "config.yaml", + }}, + }, + }}, + } + + // If the user has specified files to be mounted in the spec, add them to + // the projected config volume + if spec.Config != nil && spec.Config.Files != nil { + configVolume.Projected.Sources = append(configVolume.Projected.Sources, + spec.Config.Files...) + } + + // Create collector container + container := corev1.Container{ + Name: naming.ContainerCollector, + Image: config.CollectorContainerImage(spec), + ImagePullPolicy: pullPolicy, + Command: startCommand(logDirectories, includeLogrotate), + Env: []corev1.EnvVar{ + { + Name: "K8S_POD_NAMESPACE", + ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{ + FieldPath: "metadata.namespace", + }}, + }, + { + Name: "K8S_POD_NAME", + ValueFrom: &corev1.EnvVarSource{FieldRef: &corev1.ObjectFieldSelector{ + FieldPath: "metadata.name", + }}, + }, + { + Name: "PGPASSWORD", + Value: sqlQueryPassword, + }, + }, + Resources: spec.Resources, + SecurityContext: initialize.RestrictedSecurityContext(), + VolumeMounts: append(volumeMounts, configVolumeMount), + } + + // Add any user specified environment variables to the collector container + if spec.Config != nil && spec.Config.EnvironmentVariables != nil { + container.Env = append(container.Env, spec.Config.EnvironmentVariables...) + } + + // If metrics feature is enabled and this Pod serves metrics, add the + // Prometheus port to this container + if feature.Enabled(ctx, feature.OpenTelemetryMetrics) && thisPodServesMetrics { + container.Ports = []corev1.ContainerPort{{ + ContainerPort: int32(PrometheusPort), + Name: "otel-metrics", + Protocol: corev1.ProtocolTCP, + }} + + // If the user has specified custom queries to add, put the queries + // file(s) in the projected config volume + if spec.Metrics != nil && spec.Metrics.CustomQueries != nil && + spec.Metrics.CustomQueries.Add != nil { + for _, querySet := range spec.Metrics.CustomQueries.Add { + projection := querySet.Queries.AsProjection(querySet.Name + + "/" + querySet.Queries.Key) + configVolume.Projected.Sources = append(configVolume.Projected.Sources, + corev1.VolumeProjection{ConfigMap: &projection}) + } + } + } + + // If this is a pod that uses logrotate for log rotation, add config volume + // and mount for logrotate config + if includeLogrotate { + logrotateConfigVolumeMount := corev1.VolumeMount{ + Name: "logrotate-config", + MountPath: "/etc/logrotate.d", + ReadOnly: true, + } + logrotateConfigVolume := corev1.Volume{Name: logrotateConfigVolumeMount.Name} + logrotateConfigVolume.Projected = &corev1.ProjectedVolumeSource{ + Sources: []corev1.VolumeProjection{{ + ConfigMap: &corev1.ConfigMapProjection{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: inInstanceConfigMap.Name, + }, + Items: []corev1.KeyToPath{{ + Key: "logrotate.conf", + Path: "logrotate.conf", + }}, + }, + }}, + } + container.VolumeMounts = append(container.VolumeMounts, logrotateConfigVolumeMount) + template.Spec.Volumes = append(template.Spec.Volumes, logrotateConfigVolume) + } + + // Add configVolume to the Pod's volumes and add the collector container to + // the Pod's containers + template.Spec.Volumes = append(template.Spec.Volumes, configVolume) + template.Spec.Containers = append(template.Spec.Containers, container) + + // add the OTel collector label to the Pod + initialize.Labels(template) + template.Labels[naming.LabelCollectorDiscovery] = "true" +} + +// startCommand generates the command script used by the collector container +func startCommand(logDirectories []string, includeLogrotate bool) []string { + var mkdirScript string + if len(logDirectories) != 0 { + for _, logDir := range logDirectories { + mkdirScript = mkdirScript + ` +` + shell.MakeDirectories(logDir, path.Join(logDir, "receiver")) + } + } + + var logrotateCommand string + if includeLogrotate { + logrotateCommand = `logrotate -s /tmp/logrotate.status /etc/logrotate.d/logrotate.conf` + } + + var startScript = fmt.Sprintf(` +%s +OTEL_PIDFILE=/tmp/otel.pid + +start_otel_collector() { + echo "Starting OTel Collector" + /otelcol-contrib --config %s/config.yaml & + echo $! > $OTEL_PIDFILE +} +start_otel_collector + +exec {fd}<> <(:||:) +while read -r -t 5 -u "${fd}" ||:; do + %s + if [[ "${directory}" -nt "/proc/self/fd/${fd}" ]] && kill -HUP $(head -1 ${OTEL_PIDFILE?}); + then + echo "OTel configuration changed..." + exec {fd}>&- && exec {fd}<> <(:||:) + stat --format='Loaded configuration dated %%y' "${directory}" + fi + if [[ ! -e /proc/$(head -1 ${OTEL_PIDFILE?}) ]] ; then + start_otel_collector + fi +done +`, mkdirScript, configDirectory, logrotateCommand) + + wrapper := `monitor() {` + startScript + + `}; export directory="$1"; export -f monitor; exec -a "$0" bash -ceu monitor` + + return []string{"bash", "-ceu", "--", wrapper, "collector", configDirectory} +} diff --git a/internal/collector/logrotate.conf b/internal/collector/logrotate.conf new file mode 100644 index 0000000000..5323c45642 --- /dev/null +++ b/internal/collector/logrotate.conf @@ -0,0 +1,11 @@ +%s { + rotate %d + missingok + sharedscripts + notifempty + nocompress + %s + postrotate + %s + endscript +} diff --git a/internal/collector/lt_pg16_fast_metrics.yaml b/internal/collector/lt_pg16_fast_metrics.yaml new file mode 100644 index 0000000000..8144abc144 --- /dev/null +++ b/internal/collector/lt_pg16_fast_metrics.yaml @@ -0,0 +1,51 @@ +# This list of queries configures an OTel SQL Query Receiver to read pgMonitor +# metrics from Postgres. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/sqlqueryreceiver#metrics-queries +# https://github.com/CrunchyData/pgmonitor/blob/development/sql_exporter/common/crunchy_global_collector.yml + +# NOTE: Some of the columns below can return NULL values, for which sqlqueryreceiver will warn. +# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/sqlqueryreceiver#null-values +# Those columns are retained_bytes and database and we avoid NULL by using COALESCE. + - sql: > + SELECT + s.slot_name + , s.active::int + , COALESCE(pg_wal_lsn_diff(CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() ELSE pg_current_wal_insert_lsn() END, s.restart_lsn), 0) AS retained_bytes + , COALESCE(s.database, '') + , s.slot_type + , 0 AS conflicting + , 0 AS failover + , 0 AS synced + FROM pg_catalog.pg_replication_slots s; + metrics: + - metric_name: ccp_replication_slots_active + value_column: active + description: Active state of slot. 1 = true. 0 = false. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_retained_bytes + value_column: retained_bytes + description: The amount of WAL (in bytes) being retained for this slot + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_conflicting + value_column: conflicting + description: True if this logical slot conflicted with recovery (and so is now invalidated). When this column is true, check invalidation_reason column for the conflict reason. Always NULL for physical slots. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_failover + value_column: failover + description: True if this is a logical slot enabled to be synced to the standbys so that logical replication can be resumed from the new primary after failover. Always false for physical slots. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_slots_synced + value_column: synced + description: True if this is a logical slot that was synced from a primary server. On a hot standby, the slots with the synced column marked as true can neither be used for logical decoding nor dropped manually. The value of this column has no meaning on the primary server; the column value on the primary is default false for all slots but may (if leftover from a promoted standby) also be true. + attribute_columns: ["database", "slot_name", "slot_type"] + static_attributes: + server: "localhost:5432" diff --git a/internal/collector/lt_pg17_fast_metrics.yaml b/internal/collector/lt_pg17_fast_metrics.yaml new file mode 100644 index 0000000000..576ea8e4a6 --- /dev/null +++ b/internal/collector/lt_pg17_fast_metrics.yaml @@ -0,0 +1,60 @@ +# This list of queries configures an OTel SQL Query Receiver to read pgMonitor +# metrics from Postgres. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/sqlqueryreceiver#metrics-queries +# https://github.com/CrunchyData/pgmonitor/blob/development/sql_exporter/common/crunchy_global_collector.yml + + - sql: > + SELECT + s.buffers_backend AS writes + , s.buffers_backend_fsync AS fsyncs + FROM pg_catalog.pg_stat_bgwriter s; + metrics: + - metric_name: ccp_stat_io_bgwriter_writes + value_column: writes + data_type: sum + description: Number of write operations by background writers + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_io_bgwriter_fsyncs + value_column: fsyncs + data_type: sum + description: Number of fsync calls by background writers + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT + c.checkpoints_timed AS num_timed + , c.checkpoints_req AS num_requested + , c.checkpoint_write_time AS write_time + , c.checkpoint_sync_time AS sync_time + , c.buffers_checkpoint AS buffers_written + FROM pg_catalog.pg_stat_bgwriter c; + metrics: + - metric_name: ccp_stat_checkpointer_num_timed + value_column: num_timed + description: Number of scheduled checkpoints that have been performed + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_checkpointer_num_requested + value_column: num_requested + description: Number of requested checkpoints that have been performed + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_checkpointer_write_time + value_column: write_time + value_type: double + description: Total amount of time that has been spent in the portion of checkpoint processing where files are written to disk, in milliseconds + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_checkpointer_sync_time + value_column: sync_time + description: Total amount of time that has been spent in the portion of checkpoint processing where files are synchronized to disk, in milliseconds + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_checkpointer_buffers_written + description: Number of buffers written during checkpoints and restartpoints + value_column: buffers_written + static_attributes: + server: "localhost:5432" diff --git a/internal/collector/naming.go b/internal/collector/naming.go new file mode 100644 index 0000000000..c12ed89ebc --- /dev/null +++ b/internal/collector/naming.go @@ -0,0 +1,28 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +const CompactingProcessor = "groupbyattrs/compact" +const DebugExporter = "debug" +const LogsBatchProcessor = "batch/logs" +const OneSecondBatchProcessor = "batch/1s" +const SubSecondBatchProcessor = "batch/200ms" +const Prometheus = "prometheus/cpk-monitoring" +const PrometheusPort = 9187 +const PGBouncerMetrics = "metrics/pgbouncer" +const PostgresMetrics = "metrics/postgres" +const PatroniMetrics = "metrics/patroni" +const ResourceDetectionProcessor = "resourcedetection" +const MonitoringUser = "ccp_monitoring" + +const SqlQuery = "sqlquery" + +// For slow queries, we'll use pgMonitor's default 5 minute interval. +// https://github.com/CrunchyData/pgmonitor-extension/blob/main/sql/matviews/matviews.sql +const FiveMinuteSqlQuery = "sqlquery/300s" + +// We'll use pgMonitor's Prometheus collection interval for most queries. +// https://github.com/CrunchyData/pgmonitor/blob/development/prometheus/linux/crunchy-prometheus.yml +const FiveSecondSqlQuery = "sqlquery/5s" diff --git a/internal/collector/patroni.go b/internal/collector/patroni.go new file mode 100644 index 0000000000..e3b56718d8 --- /dev/null +++ b/internal/collector/patroni.go @@ -0,0 +1,190 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + "slices" + "strconv" + + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func EnablePatroniLogging(ctx context.Context, + inCluster *v1beta1.PostgresCluster, + outConfig *Config, +) { + var spec *v1beta1.InstrumentationLogsSpec + if inCluster != nil && inCluster.Spec.Instrumentation != nil { + spec = inCluster.Spec.Instrumentation.Logs + } + + if OpenTelemetryLogsEnabled(ctx, inCluster) { + directory := naming.PatroniPGDataLogPath + + // Keep track of what log records and files have been processed. + // Use a subdirectory of the logs directory to stay within the same failure domain. + // TODO(log-rotation): Create this directory during Collector startup. + // + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/extension/storage/filestorage#readme + outConfig.Extensions["file_storage/patroni_logs"] = map[string]any{ + "directory": directory + "/receiver", + "create_directory": true, + "fsync": true, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/filelogreceiver#readme + outConfig.Receivers["filelog/patroni_jsonlog"] = map[string]any{ + // Read the JSON files and keep track of what has been processed. + // When patroni rotates its log files, it renames the old .log file + // to .log.1. We want the collector to ingest logs from both files + // as it is possible that patroni will continue to write a log + // record or two to the old file while rotation is occurring. The + // collector knows not to create duplicate logs. + "include": []string{ + directory + "/*.log", directory + "/*.log.1", + }, + "storage": "file_storage/patroni_logs", + + "operators": []map[string]any{ + {"type": "move", "from": "body", "to": "body.original"}, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/resourceprocessor#readme + outConfig.Processors["resource/patroni"] = map[string]any{ + "attributes": []map[string]any{ + // Container and Namespace names need no escaping because they are DNS labels. + // Pod names need no escaping because they are DNS subdomains. + // + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/resource/k8s.md + {"action": "insert", "key": "k8s.container.name", "value": naming.ContainerDatabase}, + {"action": "insert", "key": "k8s.namespace.name", "value": "${env:K8S_POD_NAMESPACE}"}, + {"action": "insert", "key": "k8s.pod.name", "value": "${env:K8S_POD_NAME}"}, + {"action": "insert", "key": "process.executable.name", "value": "patroni"}, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/transformprocessor#readme + outConfig.Processors["transform/patroni_logs"] = map[string]any{ + "log_statements": []map[string]any{{ + "statements": []string{ + `set(instrumentation_scope.name, "patroni")`, + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/ottlfuncs#parsejson + `set(log.cache, ParseJSON(log.body["original"]))`, + + // The log severity is in the "levelname" field. + // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext + `set(log.severity_text, log.cache["levelname"])`, + + // Map Patroni (python) "logging levels" to OpenTelemetry severity levels. + // + // https://docs.python.org/3.6/library/logging.html#logging-levels + // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber + // https://github.com/open-telemetry/opentelemetry-python/blob/v1.29.0/opentelemetry-api/src/opentelemetry/_logs/severity/__init__.py + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/contexts/ottllog#enums + `set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == "DEBUG"`, + `set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == "INFO"`, + `set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == "WARNING"`, + `set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == "ERROR"`, + `set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == "CRITICAL"`, + + // Parse the "asctime" field into the record timestamp. + // The format is neither RFC 3339 nor ISO 8601: + // + // The date and time are separated by a single space U+0020, + // followed by a comma U+002C, then milliseconds. + // + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/stanza/docs/types/timestamp.md + // https://docs.python.org/3.6/library/logging.html#logging.LogRecord + `set(log.time, Time(log.cache["asctime"], "%F %T,%L")) where IsString(log.cache["asctime"])`, + + // Keep the unparsed log record in a standard attribute, and replace + // the log record body with the message field. + // + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/general/logs.md + `set(log.attributes["log.record.original"], log.body["original"])`, + `set(log.body, log.cache["message"])`, + }, + }}, + } + + // If there are exporters to be added to the logs pipelines defined in + // the spec, add them to the pipeline. Otherwise, add the DebugExporter. + exporters := []ComponentID{DebugExporter} + if spec != nil && spec.Exporters != nil { + exporters = slices.Clone(spec.Exporters) + } + + outConfig.Pipelines["logs/patroni"] = Pipeline{ + Extensions: []ComponentID{"file_storage/patroni_logs"}, + Receivers: []ComponentID{"filelog/patroni_jsonlog"}, + Processors: []ComponentID{ + "resource/patroni", + "transform/patroni_logs", + ResourceDetectionProcessor, + LogsBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + } +} + +func EnablePatroniMetrics(ctx context.Context, + inCluster *v1beta1.PostgresCluster, + outConfig *Config, +) { + if OpenTelemetryMetricsEnabled(ctx, inCluster) { + // Add Prometheus exporter + outConfig.Exporters[Prometheus] = map[string]any{ + "endpoint": "0.0.0.0:" + strconv.Itoa(PrometheusPort), + } + + // Add Prometheus Receiver + outConfig.Receivers[Prometheus] = map[string]any{ + "config": map[string]any{ + "scrape_configs": []map[string]any{ + { + "job_name": "patroni", + "scheme": "https", + "tls_config": map[string]any{ + "insecure_skip_verify": true, + }, + "scrape_interval": "10s", + "static_configs": []map[string]any{ + { + "targets": []string{ + "0.0.0.0:8008", + }, + }, + }, + }, + }, + }, + } + + // If there are exporters to be added to the metrics pipelines defined + // in the spec, add them to the pipeline. + exporters := []ComponentID{Prometheus} + if inCluster.Spec.Instrumentation.Metrics != nil && + inCluster.Spec.Instrumentation.Metrics.Exporters != nil { + exporters = append(exporters, inCluster.Spec.Instrumentation.Metrics.Exporters...) + } + + // Add Metrics Pipeline + outConfig.Pipelines[PatroniMetrics] = Pipeline{ + Receivers: []ComponentID{Prometheus}, + Processors: []ComponentID{ + SubSecondBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + } +} diff --git a/internal/collector/patroni_test.go b/internal/collector/patroni_test.go new file mode 100644 index 0000000000..3a37b14697 --- /dev/null +++ b/internal/collector/patroni_test.go @@ -0,0 +1,356 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestEnablePatroniLogging(t *testing.T) { + t.Run("EmptyInstrumentationSpec", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + config := NewConfig(nil) + cluster := new(v1beta1.PostgresCluster) + require.UnmarshalInto(t, &cluster.Spec, `{ + instrumentation: {} + }`) + + EnablePatroniLogging(ctx, cluster, config) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed +extensions: + file_storage/patroni_logs: + create_directory: true + directory: /pgdata/patroni/log/receiver + fsync: true +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/patroni: + attributes: + - action: insert + key: k8s.container.name + value: database + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: patroni + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/patroni_logs: + log_statements: + - statements: + - set(instrumentation_scope.name, "patroni") + - set(log.cache, ParseJSON(log.body["original"])) + - set(log.severity_text, log.cache["levelname"]) + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == + "CRITICAL" + - set(log.time, Time(log.cache["asctime"], "%F %T,%L")) where IsString(log.cache["asctime"]) + - set(log.attributes["log.record.original"], log.body["original"]) + - set(log.body, log.cache["message"]) +receivers: + filelog/patroni_jsonlog: + include: + - /pgdata/patroni/log/*.log + - /pgdata/patroni/log/*.log.1 + operators: + - from: body + to: body.original + type: move + storage: file_storage/patroni_logs +service: + extensions: + - file_storage/patroni_logs + pipelines: + logs/patroni: + exporters: + - debug + processors: + - resource/patroni + - transform/patroni_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/patroni_jsonlog +`) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.Instrumentation = testInstrumentationSpec() + config := NewConfig(cluster.Spec.Instrumentation) + + EnablePatroniLogging(ctx, cluster, config) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name +extensions: + file_storage/patroni_logs: + create_directory: true + directory: /pgdata/patroni/log/receiver + fsync: true +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/patroni: + attributes: + - action: insert + key: k8s.container.name + value: database + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: patroni + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/patroni_logs: + log_statements: + - statements: + - set(instrumentation_scope.name, "patroni") + - set(log.cache, ParseJSON(log.body["original"])) + - set(log.severity_text, log.cache["levelname"]) + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == + "CRITICAL" + - set(log.time, Time(log.cache["asctime"], "%F %T,%L")) where IsString(log.cache["asctime"]) + - set(log.attributes["log.record.original"], log.body["original"]) + - set(log.body, log.cache["message"]) +receivers: + filelog/patroni_jsonlog: + include: + - /pgdata/patroni/log/*.log + - /pgdata/patroni/log/*.log.1 + operators: + - from: body + to: body.original + type: move + storage: file_storage/patroni_logs +service: + extensions: + - file_storage/patroni_logs + pipelines: + logs/patroni: + exporters: + - googlecloud + processors: + - resource/patroni + - transform/patroni_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/patroni_jsonlog +`) + }) +} + +func TestEnablePatroniMetrics(t *testing.T) { + t.Run("EmptyInstrumentationSpec", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + config := NewConfig(nil) + cluster := new(v1beta1.PostgresCluster) + require.UnmarshalInto(t, &cluster.Spec, `{ + instrumentation: {} + }`) + + EnablePatroniMetrics(ctx, cluster, config) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + prometheus/cpk-monitoring: + endpoint: 0.0.0.0:9187 +extensions: {} +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resourcedetection: + detectors: [] + override: false + timeout: 30s +receivers: + prometheus/cpk-monitoring: + config: + scrape_configs: + - job_name: patroni + scheme: https + scrape_interval: 10s + static_configs: + - targets: + - 0.0.0.0:8008 + tls_config: + insecure_skip_verify: true +service: + extensions: [] + pipelines: + metrics/patroni: + exporters: + - prometheus/cpk-monitoring + processors: + - batch/200ms + - groupbyattrs/compact + receivers: + - prometheus/cpk-monitoring +`) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.Instrumentation = testInstrumentationSpec() + config := NewConfig(cluster.Spec.Instrumentation) + + EnablePatroniMetrics(ctx, cluster, config) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name + prometheus/cpk-monitoring: + endpoint: 0.0.0.0:9187 +extensions: {} +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resourcedetection: + detectors: [] + override: false + timeout: 30s +receivers: + prometheus/cpk-monitoring: + config: + scrape_configs: + - job_name: patroni + scheme: https + scrape_interval: 10s + static_configs: + - targets: + - 0.0.0.0:8008 + tls_config: + insecure_skip_verify: true +service: + extensions: [] + pipelines: + metrics/patroni: + exporters: + - prometheus/cpk-monitoring + - googlecloud + processors: + - batch/200ms + - groupbyattrs/compact + receivers: + - prometheus/cpk-monitoring +`) + + }) +} diff --git a/internal/collector/pgadmin.go b/internal/collector/pgadmin.go new file mode 100644 index 0000000000..244fc57546 --- /dev/null +++ b/internal/collector/pgadmin.go @@ -0,0 +1,135 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + "slices" + + corev1 "k8s.io/api/core/v1" + + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func EnablePgAdminLogging(ctx context.Context, spec *v1beta1.InstrumentationSpec, + configmap *corev1.ConfigMap, +) error { + if !OpenTelemetryLogsEnabled(ctx, spec) { + return nil + } + + otelConfig := NewConfig(spec) + + otelConfig.Extensions["file_storage/pgadmin_data_logs"] = map[string]any{ + "directory": "/var/lib/pgadmin/logs/receiver", + "create_directory": false, + "fsync": true, + } + + // PgAdmin/gunicorn logs are rotated by python -- python tries to emit a log + // and if the file needs to rotate, it rotates first and then emits the log. + // The collector therefore only needs to watch the single active log for + // each component. + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/filelogreceiver#readme + otelConfig.Receivers["filelog/pgadmin"] = map[string]any{ + "include": []string{"/var/lib/pgadmin/logs/pgadmin.log"}, + "storage": "file_storage/pgadmin_data_logs", + } + otelConfig.Receivers["filelog/gunicorn"] = map[string]any{ + "include": []string{"/var/lib/pgadmin/logs/gunicorn.log"}, + "storage": "file_storage/pgadmin_data_logs", + } + + otelConfig.Processors["resource/pgadmin"] = map[string]any{ + "attributes": []map[string]any{ + // Container and Namespace names need no escaping because they are DNS labels. + // Pod names need no escaping because they are DNS subdomains. + // + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/resource/k8s.md + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/general/logs.md + {"action": "insert", "key": "k8s.container.name", "value": naming.ContainerPGAdmin}, + {"action": "insert", "key": "k8s.namespace.name", "value": "${env:K8S_POD_NAMESPACE}"}, + {"action": "insert", "key": "k8s.pod.name", "value": "${env:K8S_POD_NAME}"}, + {"action": "insert", "key": "process.executable.name", "value": "pgadmin"}, + }, + } + + otelConfig.Processors["transform/pgadmin_log"] = map[string]any{ + "log_statements": []map[string]any{ + { + "statements": []string{ + // Keep the unparsed log record in a standard attribute, and replace + // the log record body with the message field. + // + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/general/logs.md + `set(log.attributes["log.record.original"], log.body)`, + `set(log.cache, ParseJSON(log.body))`, + `merge_maps(log.attributes, ExtractPatterns(log.cache["message"], "(?P[A-Z]{3}.*?[\\d]{3})"), "insert")`, + `set(log.body, log.cache["message"])`, + + // Set instrumentation scope to the "name" from each log record. + `set(instrumentation_scope.name, log.cache["name"])`, + + // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext + `set(log.severity_text, log.cache["level"])`, + `set(log.time_unix_nano, Int(log.cache["time"]*1000000000))`, + + // Map pgAdmin "logging levels" to OpenTelemetry severity levels. + // + // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber + // https://opentelemetry.io/docs/specs/otel/logs/data-model-appendix/#appendix-b-severitynumber-example-mappings + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/contexts/ottllog#enums + `set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == "DEBUG"`, + `set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == "INFO"`, + `set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == "WARNING"`, + `set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == "ERROR"`, + `set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == "CRITICAL"`, + }, + }, + }, + } + + // If there are exporters to be added to the logs pipelines defined in + // the spec, add them to the pipeline. Otherwise, add the DebugExporter. + exporters := []ComponentID{DebugExporter} + if spec != nil && spec.Logs != nil && spec.Logs.Exporters != nil { + exporters = slices.Clone(spec.Logs.Exporters) + } + + otelConfig.Pipelines["logs/pgadmin"] = Pipeline{ + Extensions: []ComponentID{"file_storage/pgadmin_data_logs"}, + Receivers: []ComponentID{"filelog/pgadmin"}, + Processors: []ComponentID{ + "resource/pgadmin", + "transform/pgadmin_log", + ResourceDetectionProcessor, + LogsBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + + otelConfig.Pipelines["logs/gunicorn"] = Pipeline{ + Extensions: []ComponentID{"file_storage/pgadmin_data_logs"}, + Receivers: []ComponentID{"filelog/gunicorn"}, + Processors: []ComponentID{ + "resource/pgadmin", + "transform/pgadmin_log", + ResourceDetectionProcessor, + LogsBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + + otelYAML, err := otelConfig.ToYAML() + if err == nil { + configmap.Data["collector.yaml"] = otelYAML + } + + return err +} diff --git a/internal/collector/pgadmin_test.go b/internal/collector/pgadmin_test.go new file mode 100644 index 0000000000..c6c86b4b37 --- /dev/null +++ b/internal/collector/pgadmin_test.go @@ -0,0 +1,263 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector_test + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + + "github.com/crunchydata/postgres-operator/internal/collector" + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestEnablePgAdminLogging(t *testing.T) { + t.Run("EmptyInstrumentationSpec", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + + ctx := feature.NewContext(context.Background(), gate) + + configmap := new(corev1.ConfigMap) + initialize.Map(&configmap.Data) + var instrumentation *v1beta1.InstrumentationSpec + require.UnmarshalInto(t, &instrumentation, `{}`) + err := collector.EnablePgAdminLogging(ctx, instrumentation, configmap) + assert.NilError(t, err) + + assert.Assert(t, cmp.MarshalMatches(configmap.Data, ` +collector.yaml: | + # Generated by postgres-operator. DO NOT EDIT. + # Your changes will not be saved. + exporters: + debug: + verbosity: detailed + extensions: + file_storage/pgadmin_data_logs: + create_directory: false + directory: /var/lib/pgadmin/logs/receiver + fsync: true + processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/pgadmin: + attributes: + - action: insert + key: k8s.container.name + value: pgadmin + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: pgadmin + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/pgadmin_log: + log_statements: + - statements: + - set(log.attributes["log.record.original"], log.body) + - set(log.cache, ParseJSON(log.body)) + - merge_maps(log.attributes, ExtractPatterns(log.cache["message"], "(?P[A-Z]{3}.*?[\\d]{3})"), + "insert") + - set(log.body, log.cache["message"]) + - set(instrumentation_scope.name, log.cache["name"]) + - set(log.severity_text, log.cache["level"]) + - set(log.time_unix_nano, Int(log.cache["time"]*1000000000)) + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == + "CRITICAL" + receivers: + filelog/gunicorn: + include: + - /var/lib/pgadmin/logs/gunicorn.log + storage: file_storage/pgadmin_data_logs + filelog/pgadmin: + include: + - /var/lib/pgadmin/logs/pgadmin.log + storage: file_storage/pgadmin_data_logs + service: + extensions: + - file_storage/pgadmin_data_logs + pipelines: + logs/gunicorn: + exporters: + - debug + processors: + - resource/pgadmin + - transform/pgadmin_log + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/gunicorn + logs/pgadmin: + exporters: + - debug + processors: + - resource/pgadmin + - transform/pgadmin_log + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/pgadmin +`)) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + + ctx := feature.NewContext(context.Background(), gate) + + var spec v1beta1.InstrumentationSpec + require.UnmarshalInto(t, &spec, `{ + config: { + exporters: { + googlecloud: { + log: { default_log_name: opentelemetry.io/collector-exported-log }, + project: google-project-name, + }, + }, + }, + logs: { exporters: [googlecloud] }, + }`) + + configmap := new(corev1.ConfigMap) + initialize.Map(&configmap.Data) + err := collector.EnablePgAdminLogging(ctx, &spec, configmap) + assert.NilError(t, err) + + assert.Assert(t, cmp.MarshalMatches(configmap.Data, ` +collector.yaml: | + # Generated by postgres-operator. DO NOT EDIT. + # Your changes will not be saved. + exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name + extensions: + file_storage/pgadmin_data_logs: + create_directory: false + directory: /var/lib/pgadmin/logs/receiver + fsync: true + processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/pgadmin: + attributes: + - action: insert + key: k8s.container.name + value: pgadmin + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: pgadmin + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/pgadmin_log: + log_statements: + - statements: + - set(log.attributes["log.record.original"], log.body) + - set(log.cache, ParseJSON(log.body)) + - merge_maps(log.attributes, ExtractPatterns(log.cache["message"], "(?P[A-Z]{3}.*?[\\d]{3})"), + "insert") + - set(log.body, log.cache["message"]) + - set(instrumentation_scope.name, log.cache["name"]) + - set(log.severity_text, log.cache["level"]) + - set(log.time_unix_nano, Int(log.cache["time"]*1000000000)) + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == + "CRITICAL" + receivers: + filelog/gunicorn: + include: + - /var/lib/pgadmin/logs/gunicorn.log + storage: file_storage/pgadmin_data_logs + filelog/pgadmin: + include: + - /var/lib/pgadmin/logs/pgadmin.log + storage: file_storage/pgadmin_data_logs + service: + extensions: + - file_storage/pgadmin_data_logs + pipelines: + logs/gunicorn: + exporters: + - googlecloud + processors: + - resource/pgadmin + - transform/pgadmin_log + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/gunicorn + logs/pgadmin: + exporters: + - googlecloud + processors: + - resource/pgadmin + - transform/pgadmin_log + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/pgadmin +`)) + }) +} diff --git a/internal/collector/pgbackrest.go b/internal/collector/pgbackrest.go new file mode 100644 index 0000000000..75cc9a55c1 --- /dev/null +++ b/internal/collector/pgbackrest.go @@ -0,0 +1,120 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "slices" + + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// The contents of "pgbackrest_logs_transforms.yaml" as JSON. +// See: https://pkg.go.dev/embed +// +//go:embed "generated/pgbackrest_logs_transforms.json" +var pgBackRestLogsTransforms json.RawMessage + +func NewConfigForPgBackrestRepoHostPod( + ctx context.Context, + spec *v1beta1.InstrumentationSpec, + repos []v1beta1.PGBackRestRepo, +) *Config { + config := NewConfig(spec) + + if OpenTelemetryLogsEnabled(ctx, spec) { + + var directory string + for _, repo := range repos { + if repo.Volume != nil { + directory = fmt.Sprintf(naming.PGBackRestRepoLogPath, repo.Name) + break + } + } + + // We should only enter this function if a PVC is assigned for a dedicated repohost + // but if we don't have one, exit early. + if directory == "" { + return config + } + + // Keep track of what log records and files have been processed. + // Use a subdirectory of the logs directory to stay within the same failure domain. + config.Extensions["file_storage/pgbackrest_logs"] = map[string]any{ + "directory": directory + "/receiver", + "create_directory": false, + "fsync": true, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/filelogreceiver#readme + config.Receivers["filelog/pgbackrest_log"] = map[string]any{ + // Read the files and keep track of what has been processed. + // We use logrotate to rotate the pgbackrest logs which renames the + // old .log file to .log.1. We want the collector to ingest logs from + // both files as it is possible that pgbackrest will continue to write + // a log record or two to the old file while rotation is occurring. + // The collector knows not to create duplicate logs. + "include": []string{ + directory + "/*.log", directory + "/*.log.1", + }, + "storage": "file_storage/pgbackrest_logs", + // pgBackRest prints logs with a log prefix, which includes a timestamp + // as long as the timestamp is not turned off in the configuration. + // When pgBackRest starts a process, it also will print a newline + // (if the file has already been written to) and a process "banner" + // which looks like "-------------------PROCESS START-------------------\n". + // Therefore we break multiline on the timestamp or the 19 dashes that start the banner. + // - https://github.com/pgbackrest/pgbackrest/blob/main/src/common/log.c#L451 + "multiline": map[string]string{ + "line_start_pattern": `^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}|^-{19}`, + }, + } + + config.Processors["resource/pgbackrest"] = map[string]any{ + "attributes": []map[string]any{ + // Container and Namespace names need no escaping because they are DNS labels. + // Pod names need no escaping because they are DNS subdomains. + // + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/resource/k8s.md + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/general/logs.md + {"action": "insert", "key": "k8s.container.name", "value": naming.PGBackRestRepoContainerName}, + {"action": "insert", "key": "k8s.namespace.name", "value": "${env:K8S_POD_NAMESPACE}"}, + {"action": "insert", "key": "k8s.pod.name", "value": "${env:K8S_POD_NAME}"}, + {"action": "insert", "key": "process.executable.name", "value": "pgbackrest"}, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/transformprocessor#readme + config.Processors["transform/pgbackrest_logs"] = map[string]any{ + "log_statements": slices.Clone(pgBackRestLogsTransforms), + } + + // If there are exporters to be added to the logs pipelines defined in + // the spec, add them to the pipeline. Otherwise, add the DebugExporter. + exporters := []ComponentID{DebugExporter} + if spec != nil && spec.Logs != nil && spec.Logs.Exporters != nil { + exporters = slices.Clone(spec.Logs.Exporters) + } + + config.Pipelines["logs/pgbackrest"] = Pipeline{ + Extensions: []ComponentID{"file_storage/pgbackrest_logs"}, + Receivers: []ComponentID{"filelog/pgbackrest_log"}, + Processors: []ComponentID{ + "resource/pgbackrest", + "transform/pgbackrest_logs", + ResourceDetectionProcessor, + LogsBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + } + return config +} diff --git a/internal/collector/pgbackrest_logs_transforms.yaml b/internal/collector/pgbackrest_logs_transforms.yaml new file mode 100644 index 0000000000..389f9d0a2c --- /dev/null +++ b/internal/collector/pgbackrest_logs_transforms.yaml @@ -0,0 +1,42 @@ +# This list of transform statements configures an OTel Transform Processor to +# parse pgbackrest logs. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/transformprocessor#readme + +- statements: + - set(instrumentation_scope.name, "pgbackrest") + - set(instrumentation_scope.schema_url, "https://opentelemetry.io/schemas/1.29.0") + + # Regex the pgbackrest log to capture the following groups: + # 1) the timestamp (form YYYY-MM-DD HH:MM:SS.sss) + # 2) the process id (form `P` + 2 or 3 digits) + # 3) the log level (form INFO, WARN, etc.) + # 4) the message (anything else, including newline -- we can do this because we have a multiline block on the receiver) + - >- + merge_maps(log.cache, + ExtractPatterns(log.body, "^(?\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3}) (?P\\d{2,3})\\s*(?\\S*): (?(?s).*)$"), + "insert") + where Len(log.body) > 0 + + # The log severity is the "error_severity" field. + # https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext + # https://pgbackrest.org/configuration.html#section-log/option-log-level-file + - set(log.severity_text, log.cache["error_severity"]) where IsString(log.cache["error_severity"]) + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == "TRACE" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG2) where log.severity_text == "DETAIL" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == "WARN" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == "ERROR" + + # https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-timestamp + - set(log.time, Time(log.cache["timestamp"], "%Y-%m-%d %H:%M:%S.%L")) where IsString(log.cache["timestamp"]) + + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/attributes-registry/process.md + - set(log.attributes["process.pid"], log.cache["process_id"]) + + # Keep the unparsed log record in a standard attribute, + # and replace the log record log.body with the message field. + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/general/logs.md + - set(log.attributes["log.record.original"], log.body) + - set(log.body, log.cache["message"]) diff --git a/internal/collector/pgbackrest_test.go b/internal/collector/pgbackrest_test.go new file mode 100644 index 0000000000..2b26d40531 --- /dev/null +++ b/internal/collector/pgbackrest_test.go @@ -0,0 +1,236 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestNewConfigForPgBackrestRepoHostPod(t *testing.T) { + t.Run("EmptyInstrumentationSpec", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + repos := []v1beta1.PGBackRestRepo{ + { + Name: "repo1", + Volume: new(v1beta1.RepoPVC), + }, + } + var instrumentation *v1beta1.InstrumentationSpec + require.UnmarshalInto(t, &instrumentation, `{}`) + + config := NewConfigForPgBackrestRepoHostPod(ctx, instrumentation, repos) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed +extensions: + file_storage/pgbackrest_logs: + create_directory: false + directory: /pgbackrest/repo1/log/receiver + fsync: true +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/pgbackrest: + attributes: + - action: insert + key: k8s.container.name + value: pgbackrest + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: pgbackrest + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/pgbackrest_logs: + log_statements: + - statements: + - set(instrumentation_scope.name, "pgbackrest") + - set(instrumentation_scope.schema_url, "https://opentelemetry.io/schemas/1.29.0") + - 'merge_maps(log.cache, ExtractPatterns(log.body, "^(?\\d{4}-\\d{2}-\\d{2} + \\d{2}:\\d{2}:\\d{2}\\.\\d{3}) (?P\\d{2,3})\\s*(?\\S*): + (?(?s).*)$"), "insert") where Len(log.body) > 0' + - set(log.severity_text, log.cache["error_severity"]) where IsString(log.cache["error_severity"]) + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == + "TRACE" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG2) where log.severity_text == + "DETAIL" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARN" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.time, Time(log.cache["timestamp"], "%Y-%m-%d %H:%M:%S.%L")) where + IsString(log.cache["timestamp"]) + - set(log.attributes["process.pid"], log.cache["process_id"]) + - set(log.attributes["log.record.original"], log.body) + - set(log.body, log.cache["message"]) +receivers: + filelog/pgbackrest_log: + include: + - /pgbackrest/repo1/log/*.log + - /pgbackrest/repo1/log/*.log.1 + multiline: + line_start_pattern: ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}|^-{19} + storage: file_storage/pgbackrest_logs +service: + extensions: + - file_storage/pgbackrest_logs + pipelines: + logs/pgbackrest: + exporters: + - debug + processors: + - resource/pgbackrest + - transform/pgbackrest_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/pgbackrest_log +`) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + repos := []v1beta1.PGBackRestRepo{ + { + Name: "repo1", + Volume: new(v1beta1.RepoPVC), + }, + } + + config := NewConfigForPgBackrestRepoHostPod(ctx, testInstrumentationSpec(), repos) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name +extensions: + file_storage/pgbackrest_logs: + create_directory: false + directory: /pgbackrest/repo1/log/receiver + fsync: true +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/pgbackrest: + attributes: + - action: insert + key: k8s.container.name + value: pgbackrest + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: pgbackrest + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/pgbackrest_logs: + log_statements: + - statements: + - set(instrumentation_scope.name, "pgbackrest") + - set(instrumentation_scope.schema_url, "https://opentelemetry.io/schemas/1.29.0") + - 'merge_maps(log.cache, ExtractPatterns(log.body, "^(?\\d{4}-\\d{2}-\\d{2} + \\d{2}:\\d{2}:\\d{2}\\.\\d{3}) (?P\\d{2,3})\\s*(?\\S*): + (?(?s).*)$"), "insert") where Len(log.body) > 0' + - set(log.severity_text, log.cache["error_severity"]) where IsString(log.cache["error_severity"]) + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == + "TRACE" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG2) where log.severity_text == + "DETAIL" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARN" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.time, Time(log.cache["timestamp"], "%Y-%m-%d %H:%M:%S.%L")) where + IsString(log.cache["timestamp"]) + - set(log.attributes["process.pid"], log.cache["process_id"]) + - set(log.attributes["log.record.original"], log.body) + - set(log.body, log.cache["message"]) +receivers: + filelog/pgbackrest_log: + include: + - /pgbackrest/repo1/log/*.log + - /pgbackrest/repo1/log/*.log.1 + multiline: + line_start_pattern: ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}|^-{19} + storage: file_storage/pgbackrest_logs +service: + extensions: + - file_storage/pgbackrest_logs + pipelines: + logs/pgbackrest: + exporters: + - googlecloud + processors: + - resource/pgbackrest + - transform/pgbackrest_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/pgbackrest_log +`) + }) +} diff --git a/internal/collector/pgbouncer.go b/internal/collector/pgbouncer.go new file mode 100644 index 0000000000..785b2b187e --- /dev/null +++ b/internal/collector/pgbouncer.go @@ -0,0 +1,209 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "slices" + "strconv" + + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// The contents of "pgbouncer_metrics_queries.yaml" as JSON. +// See: https://pkg.go.dev/embed +// +//go:embed "generated/pgbouncer_metrics_queries.json" +var pgBouncerMetricsQueries json.RawMessage + +// PGBouncerPostRotateScript is the script that is run after pgBouncer's log +// files have been rotated. The pgbouncer process is sent a sighup signal. +const PGBouncerPostRotateScript = "pkill -HUP --exact pgbouncer" + +// NewConfigForPgBouncerPod creates a config for the OTel collector container +// that runs as a sidecar in the pgBouncer Pod +func NewConfigForPgBouncerPod( + ctx context.Context, cluster *v1beta1.PostgresCluster, sqlQueryUsername string, +) *Config { + if cluster.Spec.Proxy == nil || cluster.Spec.Proxy.PGBouncer == nil { + // pgBouncer is disabled; return nil + return nil + } + + config := NewConfig(cluster.Spec.Instrumentation) + + EnablePgBouncerLogging(ctx, cluster, config) + EnablePgBouncerMetrics(ctx, cluster, config, sqlQueryUsername) + + return config +} + +// EnablePgBouncerLogging adds necessary configuration to the collector config to collect +// logs from pgBouncer when the OpenTelemetryLogging feature flag is enabled. +func EnablePgBouncerLogging(ctx context.Context, + inCluster *v1beta1.PostgresCluster, + outConfig *Config, +) { + var spec *v1beta1.InstrumentationLogsSpec + if inCluster != nil && inCluster.Spec.Instrumentation != nil { + spec = inCluster.Spec.Instrumentation.Logs + } + + if OpenTelemetryLogsEnabled(ctx, inCluster) { + directory := naming.PGBouncerLogPath + + // Keep track of what log records and files have been processed. + // Use a subdirectory of the logs directory to stay within the same failure domain. + // + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/extension/storage/filestorage#readme + outConfig.Extensions["file_storage/pgbouncer_logs"] = map[string]any{ + "directory": directory + "/receiver", + "create_directory": false, + "fsync": true, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/filelogreceiver#readme + outConfig.Receivers["filelog/pgbouncer_log"] = map[string]any{ + // Read the log files and keep track of what has been processed. + // We want to watch the ".log.1" file as well as it is possible that + // a log entry or two will end up there after the original ".log" + // file is renamed to ".log.1" during rotation. OTel will not create + // duplicate log entries. + "include": []string{directory + "/*.log", directory + "/*.log.1"}, + "storage": "file_storage/pgbouncer_logs", + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/resourceprocessor#readme + outConfig.Processors["resource/pgbouncer"] = map[string]any{ + "attributes": []map[string]any{ + // Container and Namespace names need no escaping because they are DNS labels. + // Pod names need no escaping because they are DNS subdomains. + // + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/resource/k8s.md + {"action": "insert", "key": "k8s.container.name", "value": naming.ContainerPGBouncer}, + {"action": "insert", "key": "k8s.namespace.name", "value": "${env:K8S_POD_NAMESPACE}"}, + {"action": "insert", "key": "k8s.pod.name", "value": "${env:K8S_POD_NAME}"}, + {"action": "insert", "key": "process.executable.name", "value": "pgbouncer"}, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/transformprocessor#readme + outConfig.Processors["transform/pgbouncer_logs"] = map[string]any{ + "log_statements": []map[string]any{{ + "statements": []string{ + // Set instrumentation scope + `set(instrumentation_scope.name, "pgbouncer")`, + + // Extract timestamp, pid, log level, and message and store in cache. + `merge_maps(log.cache, ExtractPatterns(log.body, ` + + `"^(?\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}\\.\\d{3} [A-Z]{3}) ` + + `\\[(?\\d+)\\] (?[A-Z]+) (?.*$)"), "insert")`, + + // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext + `set(log.severity_text, log.cache["log_level"])`, + + // Map pgBouncer (libusual) "logging levels" to OpenTelemetry severity levels. + // + // https://github.com/libusual/libusual/blob/master/usual/logging.c + // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber + // https://opentelemetry.io/docs/specs/otel/logs/data-model-appendix/#appendix-b-severitynumber-example-mappings + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/contexts/ottllog#enums + `set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == "NOISE" or log.severity_text == "DEBUG"`, + `set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == "LOG"`, + `set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == "WARNING"`, + `set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == "ERROR"`, + `set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == "FATAL"`, + + // Parse the timestamp. + // The format is neither RFC 3339 nor ISO 8601: + // + // The date and time are separated by a single space U+0020, + // followed by a dot U+002E, milliseconds, another space U+0020, + // then a timezone abbreviation. + // + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/stanza/docs/types/timestamp.md + `set(log.time, Time(log.cache["timestamp"], "%F %T.%L %Z")) where IsString(log.cache["timestamp"])`, + + // Keep the unparsed log record in a standard attribute, and replace + // the log record body with the message field. + // + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/general/logs.md + `set(log.attributes["log.record.original"], log.body)`, + + // Set pid as attribute + `set(log.attributes["process.pid"], log.cache["pid"])`, + + // Set the log message to body. + `set(log.body, log.cache["msg"])`, + }, + }}, + } + + // If there are exporters to be added to the logs pipelines defined in + // the spec, add them to the pipeline. Otherwise, add the DebugExporter. + exporters := []ComponentID{DebugExporter} + if spec != nil && spec.Exporters != nil { + exporters = slices.Clone(spec.Exporters) + } + + outConfig.Pipelines["logs/pgbouncer"] = Pipeline{ + Extensions: []ComponentID{"file_storage/pgbouncer_logs"}, + Receivers: []ComponentID{"filelog/pgbouncer_log"}, + Processors: []ComponentID{ + "resource/pgbouncer", + "transform/pgbouncer_logs", + ResourceDetectionProcessor, + LogsBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + } +} + +// EnablePgBouncerMetrics adds necessary configuration to the collector config to scrape +// metrics from pgBouncer when the OpenTelemetryMetrics feature flag is enabled. +func EnablePgBouncerMetrics(ctx context.Context, inCluster *v1beta1.PostgresCluster, + config *Config, sqlQueryUsername string) { + + if OpenTelemetryMetricsEnabled(ctx, inCluster) { + // Add Prometheus exporter + config.Exporters[Prometheus] = map[string]any{ + "endpoint": "0.0.0.0:" + strconv.Itoa(PrometheusPort), + } + + // Add SqlQuery Receiver + config.Receivers[SqlQuery] = map[string]any{ + "driver": "postgres", + "datasource": fmt.Sprintf( + `host=localhost dbname=pgbouncer port=5432 user=%s password=${env:PGPASSWORD}`, + sqlQueryUsername), + "queries": slices.Clone(pgBouncerMetricsQueries), + } + + // If there are exporters to be added to the metrics pipelines defined + // in the spec, add them to the pipeline. + exporters := []ComponentID{Prometheus} + if inCluster.Spec.Instrumentation.Metrics != nil && + inCluster.Spec.Instrumentation.Metrics.Exporters != nil { + exporters = append(exporters, inCluster.Spec.Instrumentation.Metrics.Exporters...) + } + + // Add Metrics Pipeline + config.Pipelines[PGBouncerMetrics] = Pipeline{ + Receivers: []ComponentID{SqlQuery}, + Processors: []ComponentID{ + SubSecondBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + } +} diff --git a/internal/collector/pgbouncer_metrics_queries.yaml b/internal/collector/pgbouncer_metrics_queries.yaml new file mode 100644 index 0000000000..21cd0ae6ee --- /dev/null +++ b/internal/collector/pgbouncer_metrics_queries.yaml @@ -0,0 +1,103 @@ +# This list of queries configures an OTel SQL Query Receiver to read pgMonitor +# metrics from PgBouncer. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/sqlqueryreceiver#metrics-queries +# https://github.com/CrunchyData/pgmonitor/blob/v5.1.1/sql_exporter/common/crunchy_pgbouncer_121_collector.yml + + - sql: "SHOW CLIENTS;" + metrics: + - metric_name: ccp_pgbouncer_clients_wait_seconds + value_column: wait + attribute_columns: ["database", "user", "state", "application_name", "link"] + description: "Current waiting time in seconds" + + # NOTE: Avoid collecting/using "host", "force_user", and "pool_mode" columns because they + # can be NULL; the collector will warn against NULL even when not used. But it will emit + # an error log if those columns are used. + # The host column should always point either to pgBouncer's virtual database (the null case) or to the primary. + - sql: "SHOW DATABASES;" + metrics: + - metric_name: ccp_pgbouncer_databases_pool_size + value_column: pool_size + attribute_columns: ["name", "port", "database"] + description: "Maximum number of server connections" + + - metric_name: ccp_pgbouncer_databases_min_pool_size + value_column: min_pool_size + attribute_columns: ["name", "port", "database"] + description: "Minimum number of server connections" + + - metric_name: ccp_pgbouncer_databases_reserve_pool_size + value_column: reserve_pool_size + attribute_columns: ["name", "port", "database"] + description: "Maximum number of additional connections for this database" + + - metric_name: ccp_pgbouncer_databases_max_connections + value_column: max_connections + attribute_columns: ["name", "port", "database"] + description: >- + Maximum number of allowed connections for this database, + as set by max_db_connections, either globally or per database + + - metric_name: ccp_pgbouncer_databases_current_connections + value_column: current_connections + attribute_columns: ["name", "port", "database"] + description: "Current number of connections for this database" + + - metric_name: ccp_pgbouncer_databases_paused + value_column: paused + attribute_columns: ["name", "port", "database"] + description: "1 if this database is currently paused, else 0" + + - metric_name: ccp_pgbouncer_databases_disabled + value_column: disabled + attribute_columns: ["name", "port", "database"] + description: "1 if this database is currently disabled, else 0" + + - sql: "SHOW LISTS;" + metrics: + - metric_name: ccp_pgbouncer_lists_item_count + value_column: items + attribute_columns: ["list"] + description: "Count of items registered with pgBouncer" + + - sql: "SHOW POOLS;" + metrics: + - metric_name: ccp_pgbouncer_pools_client_active + value_column: cl_active + attribute_columns: ["database", "user"] + description: >- + Client connections that are either linked to server connections or + are idle with no queries waiting to be processed + + - metric_name: ccp_pgbouncer_pools_client_waiting + value_column: cl_waiting + attribute_columns: ["database", "user"] + description: "Client connections that have sent queries but have not yet got a server connection" + + - metric_name: ccp_pgbouncer_pools_server_active + value_column: sv_active + attribute_columns: ["database", "user"] + description: "Server connections that are linked to a client" + + - metric_name: ccp_pgbouncer_pools_server_idle + value_column: sv_idle + attribute_columns: ["database", "user"] + description: "Server connections that are unused and immediately usable for client queries" + + - metric_name: ccp_pgbouncer_pools_server_used + value_column: sv_used + attribute_columns: ["database", "user"] + description: >- + Server connections that have been idle for more than server_check_delay, + so they need server_check_query to run on them before they can be used again + + - sql: "SHOW SERVERS;" + metrics: + - metric_name: ccp_pgbouncer_servers_close_needed + value_column: close_needed + attribute_columns: ["database", "user", "state", "application_name", "link"] + description: >- + 1 if the connection will be closed as soon as possible, + because a configuration file reload or DNS update changed the connection information + or RECONNECT was issued diff --git a/internal/collector/pgbouncer_test.go b/internal/collector/pgbouncer_test.go new file mode 100644 index 0000000000..34f2ccf328 --- /dev/null +++ b/internal/collector/pgbouncer_test.go @@ -0,0 +1,578 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestEnablePgBouncerLogging(t *testing.T) { + t.Run("EmptyInstrumentationSpec", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + config := NewConfig(nil) + cluster := new(v1beta1.PostgresCluster) + require.UnmarshalInto(t, &cluster.Spec, `{ + instrumentation: {} + }`) + EnablePgBouncerLogging(ctx, cluster, config) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed +extensions: + file_storage/pgbouncer_logs: + create_directory: false + directory: /tmp/receiver + fsync: true +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/pgbouncer: + attributes: + - action: insert + key: k8s.container.name + value: pgbouncer + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: pgbouncer + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/pgbouncer_logs: + log_statements: + - statements: + - set(instrumentation_scope.name, "pgbouncer") + - merge_maps(log.cache, ExtractPatterns(log.body, "^(?\\d{4}-\\d{2}-\\d{2} + \\d{2}:\\d{2}:\\d{2}\\.\\d{3} [A-Z]{3}) \\[(?\\d+)\\] (?[A-Z]+) + (?.*$)"), "insert") + - set(log.severity_text, log.cache["log_level"]) + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "NOISE" or log.severity_text == "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "LOG" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == + "FATAL" + - set(log.time, Time(log.cache["timestamp"], "%F %T.%L %Z")) where IsString(log.cache["timestamp"]) + - set(log.attributes["log.record.original"], log.body) + - set(log.attributes["process.pid"], log.cache["pid"]) + - set(log.body, log.cache["msg"]) +receivers: + filelog/pgbouncer_log: + include: + - /tmp/*.log + - /tmp/*.log.1 + storage: file_storage/pgbouncer_logs +service: + extensions: + - file_storage/pgbouncer_logs + pipelines: + logs/pgbouncer: + exporters: + - debug + processors: + - resource/pgbouncer + - transform/pgbouncer_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/pgbouncer_log +`) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + config := NewConfig(testInstrumentationSpec()) + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.Instrumentation = testInstrumentationSpec() + + EnablePgBouncerLogging(ctx, cluster, config) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name +extensions: + file_storage/pgbouncer_logs: + create_directory: false + directory: /tmp/receiver + fsync: true +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/pgbouncer: + attributes: + - action: insert + key: k8s.container.name + value: pgbouncer + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: pgbouncer + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/pgbouncer_logs: + log_statements: + - statements: + - set(instrumentation_scope.name, "pgbouncer") + - merge_maps(log.cache, ExtractPatterns(log.body, "^(?\\d{4}-\\d{2}-\\d{2} + \\d{2}:\\d{2}:\\d{2}\\.\\d{3} [A-Z]{3}) \\[(?\\d+)\\] (?[A-Z]+) + (?.*$)"), "insert") + - set(log.severity_text, log.cache["log_level"]) + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "NOISE" or log.severity_text == "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "LOG" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == + "FATAL" + - set(log.time, Time(log.cache["timestamp"], "%F %T.%L %Z")) where IsString(log.cache["timestamp"]) + - set(log.attributes["log.record.original"], log.body) + - set(log.attributes["process.pid"], log.cache["pid"]) + - set(log.body, log.cache["msg"]) +receivers: + filelog/pgbouncer_log: + include: + - /tmp/*.log + - /tmp/*.log.1 + storage: file_storage/pgbouncer_logs +service: + extensions: + - file_storage/pgbouncer_logs + pipelines: + logs/pgbouncer: + exporters: + - googlecloud + processors: + - resource/pgbouncer + - transform/pgbouncer_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/pgbouncer_log +`) + }) +} + +func TestEnablePgBouncerMetrics(t *testing.T) { + t.Run("EmptyInstrumentationSpec", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + config := NewConfig(nil) + cluster := new(v1beta1.PostgresCluster) + require.UnmarshalInto(t, &cluster.Spec, `{ + instrumentation: {} + }`) + EnablePgBouncerMetrics(ctx, cluster, config, "test_user") + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + prometheus/cpk-monitoring: + endpoint: 0.0.0.0:9187 +extensions: {} +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resourcedetection: + detectors: [] + override: false + timeout: 30s +receivers: + sqlquery: + datasource: host=localhost dbname=pgbouncer port=5432 user=test_user password=${env:PGPASSWORD} + driver: postgres + queries: + - metrics: + - attribute_columns: + - database + - user + - state + - application_name + - link + description: Current waiting time in seconds + metric_name: ccp_pgbouncer_clients_wait_seconds + value_column: wait + sql: SHOW CLIENTS; + - metrics: + - attribute_columns: + - name + - port + - database + description: Maximum number of server connections + metric_name: ccp_pgbouncer_databases_pool_size + value_column: pool_size + - attribute_columns: + - name + - port + - database + description: Minimum number of server connections + metric_name: ccp_pgbouncer_databases_min_pool_size + value_column: min_pool_size + - attribute_columns: + - name + - port + - database + description: Maximum number of additional connections for this database + metric_name: ccp_pgbouncer_databases_reserve_pool_size + value_column: reserve_pool_size + - attribute_columns: + - name + - port + - database + description: Maximum number of allowed connections for this database, as set + by max_db_connections, either globally or per database + metric_name: ccp_pgbouncer_databases_max_connections + value_column: max_connections + - attribute_columns: + - name + - port + - database + description: Current number of connections for this database + metric_name: ccp_pgbouncer_databases_current_connections + value_column: current_connections + - attribute_columns: + - name + - port + - database + description: 1 if this database is currently paused, else 0 + metric_name: ccp_pgbouncer_databases_paused + value_column: paused + - attribute_columns: + - name + - port + - database + description: 1 if this database is currently disabled, else 0 + metric_name: ccp_pgbouncer_databases_disabled + value_column: disabled + sql: SHOW DATABASES; + - metrics: + - attribute_columns: + - list + description: Count of items registered with pgBouncer + metric_name: ccp_pgbouncer_lists_item_count + value_column: items + sql: SHOW LISTS; + - metrics: + - attribute_columns: + - database + - user + description: Client connections that are either linked to server connections + or are idle with no queries waiting to be processed + metric_name: ccp_pgbouncer_pools_client_active + value_column: cl_active + - attribute_columns: + - database + - user + description: Client connections that have sent queries but have not yet got + a server connection + metric_name: ccp_pgbouncer_pools_client_waiting + value_column: cl_waiting + - attribute_columns: + - database + - user + description: Server connections that are linked to a client + metric_name: ccp_pgbouncer_pools_server_active + value_column: sv_active + - attribute_columns: + - database + - user + description: Server connections that are unused and immediately usable for + client queries + metric_name: ccp_pgbouncer_pools_server_idle + value_column: sv_idle + - attribute_columns: + - database + - user + description: Server connections that have been idle for more than server_check_delay, + so they need server_check_query to run on them before they can be used again + metric_name: ccp_pgbouncer_pools_server_used + value_column: sv_used + sql: SHOW POOLS; + - metrics: + - attribute_columns: + - database + - user + - state + - application_name + - link + description: 1 if the connection will be closed as soon as possible, because + a configuration file reload or DNS update changed the connection information + or RECONNECT was issued + metric_name: ccp_pgbouncer_servers_close_needed + value_column: close_needed + sql: SHOW SERVERS; +service: + extensions: [] + pipelines: + metrics/pgbouncer: + exporters: + - prometheus/cpk-monitoring + processors: + - batch/200ms + - groupbyattrs/compact + receivers: + - sqlquery +`) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + config := NewConfig(testInstrumentationSpec()) + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.Instrumentation = testInstrumentationSpec() + + EnablePgBouncerMetrics(ctx, cluster, config, "test_user") + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name + prometheus/cpk-monitoring: + endpoint: 0.0.0.0:9187 +extensions: {} +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resourcedetection: + detectors: [] + override: false + timeout: 30s +receivers: + sqlquery: + datasource: host=localhost dbname=pgbouncer port=5432 user=test_user password=${env:PGPASSWORD} + driver: postgres + queries: + - metrics: + - attribute_columns: + - database + - user + - state + - application_name + - link + description: Current waiting time in seconds + metric_name: ccp_pgbouncer_clients_wait_seconds + value_column: wait + sql: SHOW CLIENTS; + - metrics: + - attribute_columns: + - name + - port + - database + description: Maximum number of server connections + metric_name: ccp_pgbouncer_databases_pool_size + value_column: pool_size + - attribute_columns: + - name + - port + - database + description: Minimum number of server connections + metric_name: ccp_pgbouncer_databases_min_pool_size + value_column: min_pool_size + - attribute_columns: + - name + - port + - database + description: Maximum number of additional connections for this database + metric_name: ccp_pgbouncer_databases_reserve_pool_size + value_column: reserve_pool_size + - attribute_columns: + - name + - port + - database + description: Maximum number of allowed connections for this database, as set + by max_db_connections, either globally or per database + metric_name: ccp_pgbouncer_databases_max_connections + value_column: max_connections + - attribute_columns: + - name + - port + - database + description: Current number of connections for this database + metric_name: ccp_pgbouncer_databases_current_connections + value_column: current_connections + - attribute_columns: + - name + - port + - database + description: 1 if this database is currently paused, else 0 + metric_name: ccp_pgbouncer_databases_paused + value_column: paused + - attribute_columns: + - name + - port + - database + description: 1 if this database is currently disabled, else 0 + metric_name: ccp_pgbouncer_databases_disabled + value_column: disabled + sql: SHOW DATABASES; + - metrics: + - attribute_columns: + - list + description: Count of items registered with pgBouncer + metric_name: ccp_pgbouncer_lists_item_count + value_column: items + sql: SHOW LISTS; + - metrics: + - attribute_columns: + - database + - user + description: Client connections that are either linked to server connections + or are idle with no queries waiting to be processed + metric_name: ccp_pgbouncer_pools_client_active + value_column: cl_active + - attribute_columns: + - database + - user + description: Client connections that have sent queries but have not yet got + a server connection + metric_name: ccp_pgbouncer_pools_client_waiting + value_column: cl_waiting + - attribute_columns: + - database + - user + description: Server connections that are linked to a client + metric_name: ccp_pgbouncer_pools_server_active + value_column: sv_active + - attribute_columns: + - database + - user + description: Server connections that are unused and immediately usable for + client queries + metric_name: ccp_pgbouncer_pools_server_idle + value_column: sv_idle + - attribute_columns: + - database + - user + description: Server connections that have been idle for more than server_check_delay, + so they need server_check_query to run on them before they can be used again + metric_name: ccp_pgbouncer_pools_server_used + value_column: sv_used + sql: SHOW POOLS; + - metrics: + - attribute_columns: + - database + - user + - state + - application_name + - link + description: 1 if the connection will be closed as soon as possible, because + a configuration file reload or DNS update changed the connection information + or RECONNECT was issued + metric_name: ccp_pgbouncer_servers_close_needed + value_column: close_needed + sql: SHOW SERVERS; +service: + extensions: [] + pipelines: + metrics/pgbouncer: + exporters: + - prometheus/cpk-monitoring + - googlecloud + processors: + - batch/200ms + - groupbyattrs/compact + receivers: + - sqlquery +`) + + }) +} diff --git a/internal/collector/postgres.go b/internal/collector/postgres.go new file mode 100644 index 0000000000..892748c0a7 --- /dev/null +++ b/internal/collector/postgres.go @@ -0,0 +1,296 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "slices" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/postgres" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func NewConfigForPostgresPod(ctx context.Context, + inCluster *v1beta1.PostgresCluster, + outParameters *postgres.ParameterSet, +) *Config { + config := NewConfig(inCluster.Spec.Instrumentation) + + // Metrics + EnablePostgresMetrics(ctx, inCluster, config) + EnablePatroniMetrics(ctx, inCluster, config) + + // Logging + EnablePostgresLogging(ctx, inCluster, config, outParameters) + EnablePatroniLogging(ctx, inCluster, config) + + return config +} + +// The contents of "postgres_logs_transforms.yaml" as JSON. +// See: https://pkg.go.dev/embed +// +//go:embed "generated/postgres_logs_transforms.json" +var postgresLogsTransforms json.RawMessage + +// postgresCSVNames returns the names of fields in the CSV logs for version. +func postgresCSVNames(version int) string { + // JSON is the preferred format, so use those names. + // https://www.postgresql.org/docs/current/runtime-config-logging.html#RUNTIME-CONFIG-LOGGING-JSONLOG + + // https://www.postgresql.org/docs/8.3/runtime-config-logging.html#RUNTIME-CONFIG-LOGGING-CSVLOG + names := `timestamp,user,dbname,pid` + + `,connection_from` + // NOTE: this contains the JSON "remote_host" and "remote_port" values + `,session_id,line_num,ps,session_start,vxid,txid` + + `,error_severity,state_code,message,detail,hint` + + `,internal_query,internal_position,context,statement,cursor_position` + + `,location` // NOTE: this contains the JSON "func_name", "file_name", and "file_line_num" values + + // https://www.postgresql.org/docs/9.0/runtime-config-logging.html#RUNTIME-CONFIG-LOGGING-CSVLOG + if version >= 9 { + names += `,application_name` + } + + // https://www.postgresql.org/docs/13/runtime-config-logging.html#RUNTIME-CONFIG-LOGGING-CSVLOG + if version >= 13 { + names += `,backend_type` + } + + // https://www.postgresql.org/docs/14/runtime-config-logging.html#RUNTIME-CONFIG-LOGGING-CSVLOG + if version >= 14 { + names += `,leader_pid,query_id` + } + + return names +} + +func EnablePostgresLogging( + ctx context.Context, + inCluster *v1beta1.PostgresCluster, + outConfig *Config, + outParameters *postgres.ParameterSet, +) { + var spec *v1beta1.InstrumentationLogsSpec + if inCluster != nil && inCluster.Spec.Instrumentation != nil { + spec = inCluster.Spec.Instrumentation.Logs + } + + if OpenTelemetryLogsEnabled(ctx, inCluster) { + directory := postgres.LogDirectory() + version := inCluster.Spec.PostgresVersion + + // https://www.postgresql.org/docs/current/runtime-config-logging.html + outParameters.Add("logging_collector", "on") + outParameters.Add("log_directory", directory) + + // PostgreSQL v8.3 adds support for CSV logging, and + // PostgreSQL v15 adds support for JSON logging. The latter is preferred + // because newlines are escaped as "\n", U+005C + U+006E. + if version < 15 { + outParameters.Add("log_destination", "csvlog") + } else { + outParameters.Add("log_destination", "jsonlog") + } + + // If retentionPeriod is set in the spec, use that value; otherwise, we want + // to use a reasonably short duration. Defaulting to 1 day. + retentionPeriod := metav1.Duration{Duration: 24 * time.Hour} + if spec != nil && spec.RetentionPeriod != nil { + retentionPeriod = spec.RetentionPeriod.AsDuration() + } + + // Rotate log files according to retention. + // + // The ".log" suffix is replaced by ".csv" for CSV log files, and + // the ".log" suffix is replaced by ".json" for JSON log files. + // + // https://www.postgresql.org/docs/current/runtime-config-logging.html + for k, v := range postgres.LogRotation(retentionPeriod, "postgresql-", ".log") { + outParameters.Add(k, v) + } + + // Log in a timezone that the OpenTelemetry Collector will understand. + outParameters.Add("log_timezone", "UTC") + + // Keep track of what log records and files have been processed. + // Use a subdirectory of the logs directory to stay within the same failure domain. + // TODO(log-rotation): Create this directory during Collector startup. + // + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/extension/storage/filestorage#readme + outConfig.Extensions["file_storage/postgres_logs"] = map[string]any{ + "directory": directory + "/receiver", + "create_directory": true, + "fsync": true, + } + + // TODO(postgres-14): We can stop parsing CSV logs when 14 is EOL. + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/filelogreceiver#readme + outConfig.Receivers["filelog/postgres_csvlog"] = map[string]any{ + // Read the CSV files and keep track of what has been processed. + // The wildcard covers all potential log file names. + "include": []string{directory + "/*.csv"}, + "storage": "file_storage/postgres_logs", + + // Postgres does not escape newlines in its CSV log format. Search for + // the beginning of every record, starting with an unquoted timestamp. + // The 2nd through 5th fields are optional, so match through to the 7th field. + // This should do a decent job of not matching the middle of some SQL statement. + // + // The number of fields has changed over the years, but the first few + // are always formatted the same way. + // + // NOTE: This regexp is invoked in multi-line mode. https://go.dev/s/re2syntax + "multiline": map[string]string{ + "line_start_pattern": `^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d.\d{3} UTC` + // 1st: timestamp + `,(?:"[_\D](?:[^"]|"")*")?` + // 2nd: user name + `,(?:"[_\D](?:[^"]|"")*")?` + // 3rd: database name + `,\d*,(?:"(?:[^"]|"")+")?` + // 4–5th: process id, connection + `,[0-9a-f]+[.][0-9a-f]+,\d+,`, // 6–7th: session id, session line + }, + + // Differentiate these from the JSON ones below. + "operators": []map[string]any{ + {"type": "move", "from": "body", "to": "body.original"}, + {"type": "add", "field": "body.format", "value": "csv"}, + {"type": "add", "field": "body.headers", "value": postgresCSVNames(version)}, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/filelogreceiver#readme + outConfig.Receivers["filelog/postgres_jsonlog"] = map[string]any{ + // Read the JSON files and keep track of what has been processed. + // The wildcard covers all potential log file names. + "include": []string{directory + "/*.json"}, + "storage": "file_storage/postgres_logs", + + // Differentiate these from the CSV ones above. + // TODO(postgres-14): We can stop parsing CSV logs when 14 is EOL. + "operators": []map[string]any{ + {"type": "move", "from": "body", "to": "body.original"}, + {"type": "add", "field": "body.format", "value": "json"}, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/resourceprocessor#readme + outConfig.Processors["resource/postgres"] = map[string]any{ + "attributes": []map[string]any{ + // Container and Namespace names need no escaping because they are DNS labels. + // Pod names need no escaping because they are DNS subdomains. + // + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/resource/k8s.md + {"action": "insert", "key": "k8s.container.name", "value": naming.ContainerDatabase}, + {"action": "insert", "key": "k8s.namespace.name", "value": "${env:K8S_POD_NAMESPACE}"}, + {"action": "insert", "key": "k8s.pod.name", "value": "${env:K8S_POD_NAME}"}, + {"action": "insert", "key": "process.executable.name", "value": "postgres"}, + + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/database#readme + {"action": "insert", "key": "db.system", "value": "postgresql"}, + {"action": "insert", "key": "db.version", "value": fmt.Sprint(inCluster.Spec.PostgresVersion)}, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/transformprocessor#readme + outConfig.Processors["transform/postgres_logs"] = map[string]any{ + "log_statements": slices.Clone(postgresLogsTransforms), + } + + // If there are exporters to be added to the logs pipelines defined in + // the spec, add them to the pipeline. Otherwise, add the DebugExporter. + exporters := []ComponentID{DebugExporter} + if spec != nil && spec.Exporters != nil { + exporters = slices.Clone(spec.Exporters) + } + + outConfig.Pipelines["logs/postgres"] = Pipeline{ + Extensions: []ComponentID{"file_storage/postgres_logs"}, + // TODO(logs): Choose only one receiver, maybe? + Receivers: []ComponentID{ + "filelog/postgres_csvlog", + "filelog/postgres_jsonlog", + }, + Processors: []ComponentID{ + "resource/postgres", + "transform/postgres_logs", + ResourceDetectionProcessor, + LogsBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + + // pgBackRest pipeline + outConfig.Extensions["file_storage/pgbackrest_logs"] = map[string]any{ + "directory": naming.PGBackRestPGDataLogPath + "/receiver", + "create_directory": false, + "fsync": true, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/filelogreceiver#readme + outConfig.Receivers["filelog/pgbackrest_log"] = map[string]any{ + // We use logrotate to rotate the pgbackrest logs which renames the + // old .log file to .log.1. We want the collector to ingest logs from + // both files as it is possible that pgbackrest will continue to write + // a log record or two to the old file while rotation is occurring. + // The collector knows not to create duplicate logs. + "include": []string{ + naming.PGBackRestPGDataLogPath + "/*.log", + naming.PGBackRestPGDataLogPath + "/*.log.1", + }, + "storage": "file_storage/pgbackrest_logs", + + // pgBackRest prints logs with a log prefix, which includes a timestamp + // as long as the timestamp is not turned off in the configuration. + // When pgBackRest starts a process, it also will print a newline + // (if the file has already been written to) and a process "banner" + // which looks like "-------------------PROCESS START-------------------\n". + // Therefore we break multiline on the timestamp or the 19 dashes that start the banner. + // - https://github.com/pgbackrest/pgbackrest/blob/main/src/common/log.c#L451 + "multiline": map[string]string{ + "line_start_pattern": `^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}|^-{19}`, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/resourceprocessor#readme + outConfig.Processors["resource/pgbackrest"] = map[string]any{ + "attributes": []map[string]any{ + // Container and Namespace names need no escaping because they are DNS labels. + // Pod names need no escaping because they are DNS subdomains. + // + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names + // https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/resource/k8s.md + {"action": "insert", "key": "k8s.container.name", "value": naming.ContainerDatabase}, + {"action": "insert", "key": "k8s.namespace.name", "value": "${env:K8S_POD_NAMESPACE}"}, + {"action": "insert", "key": "k8s.pod.name", "value": "${env:K8S_POD_NAME}"}, + {"action": "insert", "key": "process.executable.name", "value": "pgbackrest"}, + }, + } + + // https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/transformprocessor#readme + outConfig.Processors["transform/pgbackrest_logs"] = map[string]any{ + "log_statements": slices.Clone(pgBackRestLogsTransforms), + } + + outConfig.Pipelines["logs/pgbackrest"] = Pipeline{ + Extensions: []ComponentID{"file_storage/pgbackrest_logs"}, + Receivers: []ComponentID{"filelog/pgbackrest_log"}, + Processors: []ComponentID{ + "resource/pgbackrest", + "transform/pgbackrest_logs", + ResourceDetectionProcessor, + LogsBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + } +} diff --git a/internal/collector/postgres_5m_metrics.yaml b/internal/collector/postgres_5m_metrics.yaml new file mode 100644 index 0000000000..5c06e63660 --- /dev/null +++ b/internal/collector/postgres_5m_metrics.yaml @@ -0,0 +1,49 @@ +# This list of queries configures an OTel SQL Query Receiver to read pgMonitor +# metrics from Postgres. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/sqlqueryreceiver#metrics-queries +# https://github.com/CrunchyData/pgmonitor/blob/development/sql_exporter/common/crunchy_global_collector.yml + - sql: > + SELECT datname as dbname + , pg_catalog.pg_database_size(datname) as bytes + FROM pg_catalog.pg_database + WHERE datistemplate = false; + metrics: + - metric_name: ccp_database_size_bytes + value_column: bytes + description: Database size in bytes + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + + # Returns count of sequences that have used up 75% of what's available. + # https://github.com/CrunchyData/pgmonitor-extension/blob/main/sql/functions/functions.sql#L67 + # NOTE: Postgres 13 requires an alias, x below, where PG 17 doesn't. + - sql: > + SELECT count(*) AS count + FROM ( + SELECT CEIL((s.max_value-min_value::NUMERIC+1)/s.increment_by::NUMERIC) AS slots + , CEIL((COALESCE(s.last_value,s.min_value)-s.min_value::NUMERIC+1)/s.increment_by::NUMERIC) AS used + FROM pg_catalog.pg_sequences s + ) x + WHERE (ROUND(used/slots*100)::int) > 75; + metrics: + - metric_name: ccp_sequence_exhaustion_count + value_column: count + description: | + Count of sequences that have reached greater than or equal to 75% of their max available numbers. + Function monitor.sequence_status() can provide more details if run directly on system. + static_attributes: + server: "localhost:5432" + + - sql: SELECT monitor.pg_hba_checksum() AS status; + metrics: + - metric_name: ccp_pg_hba_checksum_status + value_column: status + description: | + Value of checksum monitoring status for pg_catalog.pg_hba_file_rules (pg_hba.conf). + 0 = valid config. 1 = settings changed. + Settings history is available for review in the table `monitor.pg_hba_checksum`. + To reset current config to valid after alert, run monitor.pg_hba_checksum_set_valid(). Note this will clear the history table. + static_attributes: + server: "localhost:5432" diff --git a/internal/collector/postgres_5m_per_db_metrics.yaml b/internal/collector/postgres_5m_per_db_metrics.yaml new file mode 100644 index 0000000000..6fcefcf9d0 --- /dev/null +++ b/internal/collector/postgres_5m_per_db_metrics.yaml @@ -0,0 +1,161 @@ +# This list of queries configures an OTel SQL Query Receiver to read pgMonitor +# metrics from Postgres. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/sqlqueryreceiver#metrics-queries +# https://github.com/CrunchyData/pgmonitor/blob/v5.2.1/sql_exporter/common/crunchy_per_db_collector.yml +# +# Note: Several metrics in the `crunchy_per_db_collector` track the materialized views and +# pgMonitor-extension version -- metrics that aren't meaningful in the CPK environment. +# The list of metrics that fall into this category include +# * ccp_metric_matview_refresh_last_run_fail_count +# * ccp_metric_matview_refresh_longest_runtime_seconds +# * ccp_metric_matview_refresh_longest_runtime +# * ccp_metric_table_refresh_longest_runtime +# * ccp_pgmonitor_extension_per_db + + - sql: > + SELECT current_database() as dbname + , n.nspname as schemaname + , c.relname + , pg_catalog.pg_total_relation_size(c.oid) as bytes + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid + WHERE NOT pg_is_other_temp_schema(n.oid) + AND relkind IN ('r', 'm', 'f'); + metrics: + - metric_name: ccp_table_size_bytes + value_type: double + value_column: bytes + description: "Table size in bytes including indexes" + attribute_columns: ["dbname", "schemaname", "relname"] + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT current_database() as dbname + , p.schemaname + , p.relname + , p.seq_scan + , p.seq_tup_read + , COALESCE(p.idx_scan, 0) AS idx_scan + , COALESCE(p.idx_tup_fetch, 0) as idx_tup_fetch + , p.n_tup_ins + , p.n_tup_upd + , p.n_tup_del + , p.n_tup_hot_upd + , CASE + WHEN current_setting('server_version_num')::int >= 160000 + THEN p.n_tup_newpage_upd + ELSE 0::bigint + END AS n_tup_newpage_upd + , p.n_live_tup + , p.n_dead_tup + , p.vacuum_count + , p.autovacuum_count + , p.analyze_count + , p.autoanalyze_count + FROM pg_catalog.pg_stat_user_tables p; + metrics: + - metric_name: ccp_stat_user_tables_seq_scan + data_type: sum + value_column: seq_scan + description: "Number of sequential scans initiated on this table" + attribute_columns: ["dbname", "schemaname", "relname"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_user_tables_seq_tup_read + data_type: sum + value_column: seq_tup_read + description: "Number of live rows fetched by sequential scans" + attribute_columns: ["dbname", "schemaname", "relname"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_user_tables_idx_scan + data_type: sum + description: "Number of index scans initiated on this table" + value_column: idx_scan + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_idx_tup_fetch + data_type: sum + description: "Number of live rows fetched by index scans" + value_column: idx_tup_fetch + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_n_tup_ins + data_type: sum + description: "Number of rows inserted" + value_column: n_tup_ins + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_n_tup_upd + data_type: sum + description: "Number of rows updated" + value_column: n_tup_upd + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_n_tup_del + data_type: sum + description: "Number of rows deleted" + value_column: n_tup_del + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_n_tup_hot_upd + data_type: sum + description: "Number of rows HOT updated (i.e., with no separate index update required)" + value_column: n_tup_hot_upd + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_n_tup_newpage_upd + data_type: sum + description: "Number of rows updated where the successor version goes onto a new heap page, leaving behind an original version with a t_ctid field that points to a different heap page. These are always non-HOT updates." + value_column: n_tup_newpage_upd + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_n_live_tup + description: "Estimated number of live rows" + value_column: n_live_tup + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_n_dead_tup + description: "Estimated number of dead rows" + value_column: n_dead_tup + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_vacuum_count + data_type: sum + description: "Number of times this table has been manually vacuumed (not counting VACUUM FULL)" + value_column: vacuum_count + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_autovacuum_count + data_type: sum + description: "Number of times this table has been vacuumed by the autovacuum daemon" + value_column: autovacuum_count + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_analyze_count + data_type: sum + description: "Number of times this table has been manually analyzed" + value_column: analyze_count + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] + - metric_name: ccp_stat_user_tables_autoanalyze_count + data_type: sum + description: "Number of times this table has been analyzed by the autovacuum daemon" + value_column: autoanalyze_count + static_attributes: + server: "localhost:5432" + attribute_columns: ["dbname", "schemaname", "relname"] diff --git a/internal/collector/postgres_5s_metrics.yaml b/internal/collector/postgres_5s_metrics.yaml new file mode 100644 index 0000000000..d424dcb014 --- /dev/null +++ b/internal/collector/postgres_5s_metrics.yaml @@ -0,0 +1,1043 @@ +# This list of queries configures an OTel SQL Query Receiver to read pgMonitor +# metrics from Postgres. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/receiver/sqlqueryreceiver#metrics-queries +# https://github.com/CrunchyData/pgmonitor/blob/development/sql_exporter/common/crunchy_global_collector.yml +# + # TODO ccp_pg_stat_activity can be removed/replaced once an equivalent metric is added to pgMonitor + - sql: > + SELECT + pg_database.datname, + tmp.state, + COALESCE(tmp2.usename, '') as usename, + COALESCE(tmp2.application_name, '') as application_name, + COALESCE(count,0) as count, + COALESCE(max_tx_duration,0) as max_tx_duration + FROM + ( + VALUES ('active'), + ('idle'), + ('idle in transaction'), + ('idle in transaction (aborted)'), + ('fastpath function call'), + ('disabled') + ) AS tmp(state) CROSS JOIN pg_database + LEFT JOIN + ( + SELECT + datname, + state, + usename, + application_name, + count(*) AS count, + MAX(EXTRACT(EPOCH FROM now() - xact_start))::float AS max_tx_duration + FROM pg_stat_activity GROUP BY datname,state,usename,application_name) AS tmp2 + ON tmp.state = tmp2.state AND pg_database.datname = tmp2.datname; + metrics: + - metric_name: ccp_pg_stat_activity_count + value_column: count + description: number of connections in this state + attribute_columns: ["application_name", "datname", "state", "usename"] + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT + COALESCE(EXTRACT(epoch from (CURRENT_TIMESTAMP - last_archived_time)), 0) AS seconds_since_last_archive, + archived_count, + failed_count, + CASE + WHEN EXTRACT(epoch from (last_failed_time - last_archived_time)) IS NULL THEN 0 + WHEN EXTRACT(epoch from (last_failed_time - last_archived_time)) < 0 THEN 0 + ELSE EXTRACT(epoch from (last_failed_time - last_archived_time)) + END AS seconds_since_last_fail + FROM pg_catalog.pg_stat_archiver; + metrics: + - metric_name: ccp_archive_command_status_seconds_since_last_archive + value_column: seconds_since_last_archive + value_type: double + description: Seconds since the last successful archive operation + static_attributes: + server: "localhost:5432" + - metric_name: ccp_archive_command_status_archived_count + value_column: archived_count + description: Number of WAL files that have been successfully archived + static_attributes: + server: "localhost:5432" + - metric_name: ccp_archive_command_status_failed_count + value_column: failed_count + description: Number of failed attempts for archiving WAL files + static_attributes: + server: "localhost:5432" + - metric_name: ccp_archive_command_status_seconds_since_last_fail + value_column: seconds_since_last_fail + description: Seconds since the last recorded failure of the archive_command + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT ((total - idle) - idle_in_txn) as active + , total + , idle + , idle_in_txn + , (SELECT COALESCE(EXTRACT(epoch FROM (MAX(clock_timestamp() - state_change))),0) FROM pg_catalog.pg_stat_activity WHERE state = 'idle in transaction') AS max_idle_in_txn_time + , (SELECT COALESCE(EXTRACT(epoch FROM (MAX(clock_timestamp() - query_start))),0) FROM pg_catalog.pg_stat_activity WHERE backend_type = 'client backend' AND state <> 'idle' ) AS max_query_time + , (SELECT COALESCE(EXTRACT(epoch FROM (MAX(clock_timestamp() - query_start))),0) FROM pg_catalog.pg_stat_activity WHERE backend_type = 'client backend' AND wait_event_type = 'Lock' ) AS max_blocked_query_time + , max_connections + FROM ( + SELECT COUNT(*) as total + , COALESCE(SUM(CASE WHEN state = 'idle' THEN 1 ELSE 0 END),0) AS idle + , COALESCE(SUM(CASE WHEN state = 'idle in transaction' THEN 1 ELSE 0 END),0) AS idle_in_txn FROM pg_catalog.pg_stat_activity) x + JOIN (SELECT setting::float AS max_connections FROM pg_settings WHERE name = 'max_connections') xx ON (true); + metrics: + - metric_name: ccp_connection_stats_active + value_column: active + description: Total non-idle connections + static_attributes: + server: "localhost:5432" + - metric_name: ccp_connection_stats_idle + value_column: idle + description: Total idle connections + static_attributes: + server: "localhost:5432" + - metric_name: ccp_connection_stats_idle_in_txn + value_column: idle_in_txn + description: Total idle in transaction connections + static_attributes: + server: "localhost:5432" + - metric_name: ccp_connection_stats_max_blocked_query_time + value_column: max_blocked_query_time + value_type: double + description: Value of max_connections for the monitored database + static_attributes: + server: "localhost:5432" + - metric_name: ccp_connection_stats_max_connections + value_column: max_connections + description: Value of max_connections for the monitored database + static_attributes: + server: "localhost:5432" + - metric_name: ccp_connection_stats_max_idle_in_txn_time + value_column: max_idle_in_txn_time + value_type: double + description: Length of time in seconds of the longest idle in transaction session + static_attributes: + server: "localhost:5432" + - metric_name: ccp_connection_stats_max_query_time + value_column: max_query_time + value_type: double + description: Length of time in seconds of the longest running query + static_attributes: + server: "localhost:5432" + - metric_name: ccp_connection_stats_total + value_column: total + description: Total idle and non-idle connections + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT datname AS dbname + , checksum_failures AS count + , coalesce(extract(epoch from (clock_timestamp() - checksum_last_failure)), 0) AS time_since_last_failure_seconds + FROM pg_catalog.pg_stat_database + WHERE pg_stat_database.datname IS NOT NULL; + metrics: + - metric_name: ccp_data_checksum_failure_count + value_column: count + attribute_columns: ["dbname"] + description: Total number of checksum failures on this database + static_attributes: + server: "localhost:5432" + - metric_name: ccp_data_checksum_failure_time_since_last_failure_seconds + value_column: time_since_last_failure_seconds + value_type: double + attribute_columns: ["dbname"] + description: Time interval in seconds since the last checksum failure was encountered + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT pg_database.datname as dbname + , tmp.mode + , COALESCE(count,0) as count + FROM + ( + VALUES ('accesssharelock'), + ('rowsharelock'), + ('rowexclusivelock'), + ('shareupdateexclusivelock'), + ('sharelock'), + ('sharerowexclusivelock'), + ('exclusivelock'), + ('accessexclusivelock') + ) AS tmp(mode) CROSS JOIN pg_catalog.pg_database + LEFT JOIN + (SELECT database, lower(mode) AS mode,count(*) AS count + FROM pg_catalog.pg_locks WHERE database IS NOT NULL + GROUP BY database, lower(mode) + ) AS tmp2 + ON tmp.mode=tmp2.mode and pg_database.oid = tmp2.database; + metrics: + - metric_name: ccp_locks_count + value_column: count + attribute_columns: ["dbname", "mode"] + description: Number of locks per mode type + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT monitor.kdapi_scalar_bigint('cpu_request') AS request + , monitor.kdapi_scalar_bigint('cpu_limit') AS limit; + metrics: + - metric_name: ccp_nodemx_cpu_limit + value_column: limit + description: CPU limit value in milli cores + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_cpu_request + value_column: request + description: CPU request value in milli cores + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT CASE WHEN monitor.cgroup_mode() = 'legacy' + THEN monitor.cgroup_scalar_bigint('cpuacct.usage') + ELSE (SELECT val FROM monitor.cgroup_setof_kv('cpu.stat') where key = 'usage_usec') * 1000 + END AS usage, + extract(epoch from clock_timestamp()) AS usage_ts; + metrics: + - metric_name: ccp_nodemx_cpuacct_usage + value_column: usage + value_type: double + description: CPU usage in nanoseconds + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_cpuacct_usage_ts + value_column: usage_ts + value_type: double + description: CPU usage snapshot timestamp + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT + CASE + WHEN monitor.cgroup_mode() = 'legacy' THEN + monitor.cgroup_scalar_bigint('cpu.cfs_period_us') + ELSE + (monitor.cgroup_array_bigint('cpu.max'))[2] + END AS period_us, + CASE + WHEN monitor.cgroup_mode() = 'legacy' THEN + GREATEST(monitor.cgroup_scalar_bigint('cpu.cfs_quota_us'), 0) + ELSE + GREATEST((monitor.cgroup_array_bigint('cpu.max'))[1], 0) + END AS quota_us; + metrics: + - metric_name: ccp_nodemx_cpucfs_period_us + value_column: period_us + description: The total available run-time within a period (in microseconds) + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_cpucfs_quota_us + value_column: quota_us + value_type: double + description: The length of a period (in microseconds) + static_attributes: + server: "localhost:5432" + + # NOTE: cgroup v2 has throttled_usec, vs. throttled_time. + - sql: > + WITH d(key, val) AS + (select key, val from monitor.cgroup_setof_kv('cpu.stat')) + SELECT + (SELECT val FROM d WHERE key='nr_periods') AS nr_periods, + (SELECT val FROM d WHERE key='nr_throttled') AS nr_throttled, + (SELECT val FROM d WHERE key='throttled_usec') AS throttled_time, + extract(epoch from clock_timestamp()) as snap_ts; + metrics: + - metric_name: ccp_nodemx_cpustat_nr_periods + value_column: nr_periods + value_type: double + description: Number of periods that any thread was runnable + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_cpustat_nr_throttled + value_column: nr_throttled + description: Number of runnable periods in which the application used its entire quota and was throttled + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_cpustat_snap_ts + value_column: snap_ts + value_type: double + description: CPU stat snapshot timestamp + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_cpustat_throttled_time + value_column: throttled_time + value_type: double # TODO: Is this right? + description: Sum total amount of time individual threads within the monitor.cgroup were throttled + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT mount_point,fs_type,total_bytes,available_bytes,total_file_nodes,free_file_nodes + FROM monitor.proc_mountinfo() m + JOIN monitor.fsinfo(m.mount_point) f USING (major_number, minor_number) + WHERE m.mount_point IN ('/pgdata', '/pgwal') OR + m.mount_point like '/tablespaces/%'; + metrics: + - metric_name: ccp_nodemx_data_disk_available_bytes + value_column: available_bytes + value_type: double + description: Available size in bytes + attribute_columns: ["fs_type", "mount_point"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_data_disk_free_file_nodes + value_column: free_file_nodes + description: Available file nodes + attribute_columns: ["fs_type", "mount_point"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_data_disk_total_bytes + value_column: total_bytes + description: Size in bytes + attribute_columns: ["fs_type", "mount_point"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_data_disk_total_file_nodes + value_column: total_file_nodes + description: Total file nodes + attribute_columns: ["fs_type", "mount_point"] + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT mount_point,sectors_read,sectors_written + FROM monitor.proc_mountinfo() m + JOIN monitor.proc_diskstats() d USING (major_number, minor_number) + WHERE m.mount_point IN ('/pgdata', '/pgwal') OR + m.mount_point like '/tablespaces/%'; + metrics: + - metric_name: ccp_nodemx_disk_activity_sectors_read + value_column: sectors_read + description: Total sectors read + attribute_columns: ["mount_point"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_disk_activity_sectors_written + value_column: sectors_written + description: Total sectors written + attribute_columns: ["mount_point"] + static_attributes: + server: "localhost:5432" + + - sql: > + WITH d(key, val) as (SELECT key, val FROM monitor.cgroup_setof_kv('memory.stat')) + SELECT + monitor.kdapi_scalar_bigint('mem_request') AS request, + CASE + WHEN monitor.cgroup_mode() = 'legacy' THEN + (CASE WHEN monitor.cgroup_scalar_bigint('memory.limit_in_bytes') = 9223372036854771712 THEN 0 ELSE monitor.cgroup_scalar_bigint('memory.limit_in_bytes') END) + ELSE + (CASE WHEN monitor.cgroup_scalar_bigint('memory.max') = 9223372036854775807 THEN 0 ELSE monitor.cgroup_scalar_bigint('memory.max') END) + END AS limit, + CASE + WHEN monitor.cgroup_mode() = 'legacy' + THEN (SELECT val FROM d WHERE key='cache') + ELSE 0 + END as cache, + CASE + WHEN monitor.cgroup_mode() = 'legacy' + THEN (SELECT val FROM d WHERE key='rss') + ELSE 0 + END as RSS, + (SELECT val FROM d WHERE key='shmem') as shmem, + CASE + WHEN monitor.cgroup_mode() = 'legacy' + THEN (SELECT val FROM d WHERE key='mapped_file') + ELSE 0 + END as mapped_file, + CASE + WHEN monitor.cgroup_mode() = 'legacy' + THEN (SELECT val FROM d WHERE key='dirty') + ELSE (SELECT val FROM d WHERE key='file_dirty') + END as dirty, + (SELECT val FROM d WHERE key='active_anon') as active_anon, + (SELECT val FROM d WHERE key='inactive_anon') as inactive_anon, + (SELECT val FROM d WHERE key='active_file') as active_file, + (SELECT val FROM d WHERE key='inactive_file') as inactive_file, + CASE + WHEN monitor.cgroup_mode() = 'legacy' + THEN monitor.cgroup_scalar_bigint('memory.usage_in_bytes') + ELSE monitor.cgroup_scalar_bigint('memory.current') + END as usage_in_bytes, + CASE + WHEN monitor.cgroup_mode() = 'legacy' + THEN monitor.cgroup_scalar_bigint('memory.kmem.usage_in_bytes') + ELSE 0 + END as kmem_usage_in_byte; + metrics: + - metric_name: ccp_nodemx_mem_active_anon + value_column: active_anon + value_type: double + description: Total bytes of anonymous and swap cache memory on active LRU list + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_active_file + value_column: active_file + value_type: double + description: Total bytes of file-backed memory on active LRU list + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_cache + value_column: cache + value_type: double + description: Total bytes of page cache memory + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_dirty + value_column: dirty + description: Total bytes that are waiting to get written back to the disk + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_inactive_anon + value_column: inactive_anon + value_type: double + description: Total bytes of anonymous and swap cache memory on inactive LRU list + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_inactive_file + value_column: inactive_file + value_type: double + description: Total bytes of file-backed memory on inactive LRU list + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_kmem_usage_in_byte + value_column: kmem_usage_in_byte + description: Unknown metric from ccp_nodemx_mem + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_limit + value_column: limit + description: Memory limit value in bytes + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_mapped_file + value_column: mapped_file + description: Total bytes of mapped file (includes tmpfs/shmem) + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_request + value_column: request + description: Memory request value in bytes + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_rss + value_column: rss + value_type: double + description: Total bytes of anonymous and swap cache memory + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_shmem + value_column: shmem + value_type: double + description: Total bytes of shared memory + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_mem_usage_in_bytes + value_column: usage_in_bytes + description: Total usage in bytes + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT interface + ,tx_bytes + ,tx_packets + ,rx_bytes + ,rx_packets from monitor.proc_network_stats(); + metrics: + - metric_name: ccp_nodemx_network_rx_bytes + value_column: rx_bytes + description: Number of bytes received + attribute_columns: ["interface"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_network_rx_packets + value_column: rx_packets + description: Number of packets received + attribute_columns: ["interface"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_network_tx_bytes + value_column: tx_bytes + description: Number of bytes transmitted + attribute_columns: ["interface"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_nodemx_network_tx_packets + value_column: tx_packets + description: Number of packets transmitted + attribute_columns: ["interface"] + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT monitor.cgroup_process_count() as count; + metrics: + - metric_name: ccp_nodemx_process_count + value_column: count + description: Total number of database processes + static_attributes: + server: "localhost:5432" + + # Setting pg_stat_statements_reset_info to -1 means update as often as possible. + - sql: > + SELECT monitor.pg_stat_statements_reset_info(-1) as time; + metrics: + - metric_name: ccp_pg_stat_statements_reset_time + value_column: time + description: Epoch time when stats were reset + static_attributes: + server: "localhost:5432" + + + # This query against pg_stat_statements is compatible with PG 13 and later. + # https://github.com/CrunchyData/pgmonitor-extension/blob/main/sql/functions/functions.sql + # TODO: Double-check the sorting and the attribute values on the below. + - sql: > + WITH monitor AS ( + SELECT + pg_get_userbyid(s.userid) AS role + , d.datname AS dbname + , s.queryid AS queryid + , btrim(replace(left(s.query, 40), '\n', '')) AS query + , s.calls + , s.total_exec_time AS total_exec_time + , s.max_exec_time AS max_exec_time + , s.mean_exec_time AS mean_exec_time + , s.rows + , s.wal_records AS records + , s.wal_fpi AS fpi + , s.wal_bytes AS bytes + FROM public.pg_stat_statements s + JOIN pg_catalog.pg_database d ON d.oid = s.dbid + ) + SELECT role + , dbname + , queryid + , query + , max(monitor.mean_exec_time) AS top_mean_exec_time_ms + FROM monitor + GROUP BY 1,2,3,4 + ORDER BY 5 DESC + LIMIT 20; + metrics: + - metric_name: ccp_pg_stat_statements_top_mean_exec_time_ms + value_column: top_mean_exec_time_ms + value_type: double + description: Average query runtime in milliseconds + attribute_columns: ["dbname", "query", "queryid", "role"] + static_attributes: + server: "localhost:5432" + + - sql: > + WITH monitor AS ( + SELECT + pg_get_userbyid(s.userid) AS role + , d.datname AS dbname + , s.calls + , s.total_exec_time + , s.mean_exec_time + , s.rows + FROM public.pg_stat_statements s + JOIN pg_catalog.pg_database d ON d.oid = s.dbid + ) + SELECT role + , dbname + , sum(calls) AS calls_count + , sum(total_exec_time) AS exec_time_ms + , avg(mean_exec_time) AS mean_exec_time_ms + , sum(rows) AS row_count + FROM monitor + GROUP BY 1,2; + metrics: + - metric_name: ccp_pg_stat_statements_total_calls_count + value_column: calls_count + value_type: double + description: Total number of queries run per user/database + attribute_columns: ["dbname", "role"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_pg_stat_statements_total_exec_time_ms + value_column: exec_time_ms + value_type: double + description: Total runtime of all queries per user/database + attribute_columns: ["dbname", "role"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_pg_stat_statements_total_mean_exec_time_ms + value_column: mean_exec_time_ms + value_type: double + description: Total runtime of all queries per user/database + attribute_columns: ["dbname", "role"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_pg_stat_statements_total_row_count + value_column: row_count + value_type: double + description: Total rows returned from all queries per user/database + attribute_columns: ["dbname", "role"] + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT current_setting('server_version_num')::int AS current; + metrics: + - metric_name: ccp_postgresql_version_current + value_column: current + description: The current version of PostgreSQL that this exporter is running on as a 6 digit integer (######). + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT extract(epoch from (clock_timestamp() - pg_postmaster_start_time() )) AS seconds; + metrics: + - metric_name: ccp_postmaster_uptime_seconds + value_column: seconds + value_type: double + description: Time interval in seconds since PostgreSQL database was last restarted. + static_attributes: + server: "localhost:5432" + + # get_replication_lag is created in metrics_setup.sql + - sql: > + SELECT * FROM get_replication_lag(); + metrics: + - metric_name: ccp_replication_lag_size_bytes + value_column: bytes + value_type: double + description: Replication lag in bytes. + attribute_columns: ['replica'] + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT + COALESCE( + CASE + WHEN (pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()) OR (pg_is_in_recovery() = false) THEN 0 + ELSE EXTRACT (EPOCH FROM clock_timestamp() - pg_last_xact_replay_timestamp())::INTEGER + END, + 0 + ) AS replay_time, + COALESCE( + CASE + WHEN pg_is_in_recovery() = false THEN 0 + ELSE EXTRACT (EPOCH FROM clock_timestamp() - pg_last_xact_replay_timestamp())::INTEGER + END, + 0 + ) AS received_time, + CASE + WHEN pg_is_in_recovery() = true THEN 'replica' + ELSE 'primary' + END AS role, + CASE + WHEN pg_is_in_recovery() = true THEN 1 + ELSE 2 + END AS status; + metrics: + - metric_name: ccp_is_in_recovery_status + value_column: status + value_type: double + description: Return value of 1 means database is in recovery. Otherwise 2 it is a primary + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_lag_received_time + value_column: received_time + value_type: double + description: | + Length of time since the last WAL file was received and replayed on replica. + Always increases, possibly causing false positives if the primary stops writing. + Monitors for replicas that stop receiving WAL all together. + attribute_columns: ["role"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_replication_lag_replay_time + value_column: replay_time + value_type: double + description: | + Length of time since the last transaction was replayed on replica. + Returns zero if last WAL received equals last WAL replayed. Avoids + false positives when primary stops writing. Monitors for replicas that + cannot keep up with primary WAL generation. + attribute_columns: ["role"] + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT count(*) AS count FROM pg_catalog.pg_settings WHERE pending_restart = true; + metrics: + - metric_name: ccp_settings_pending_restart_count + value_column: count + description: Number of settings from pg_settings catalog in a pending_restart state + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT + buffers_clean + , maxwritten_clean + , buffers_alloc + FROM pg_catalog.pg_stat_bgwriter; + metrics: + - metric_name: ccp_stat_bgwriter_buffers_alloc + value_column: buffers_alloc + description: Number of buffers allocated + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_bgwriter_buffers_clean + value_column: buffers_clean + data_type: sum + description: Number of buffers written by the background writer + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_bgwriter_maxwritten_clean + value_column: maxwritten_clean + description: Number of times the background writer stopped a cleaning scan because it had written too many buffers + static_attributes: + server: "localhost:5432" + + - sql: > + WITH max_age AS ( + SELECT 2000000000 as max_old_xid + , setting AS autovacuum_freeze_max_age + FROM pg_catalog.pg_settings + WHERE name = 'autovacuum_freeze_max_age') + , per_database_stats AS ( + SELECT datname + , m.max_old_xid::int + , m.autovacuum_freeze_max_age::int + , age(d.datfrozenxid) AS oldest_current_xid + FROM pg_catalog.pg_database d + JOIN max_age m ON (true) + WHERE d.datallowconn) + SELECT max(oldest_current_xid) AS oldest_current_xid + , max(ROUND(100*(oldest_current_xid/max_old_xid::float))) AS percent_towards_wraparound + , max(ROUND(100*(oldest_current_xid/autovacuum_freeze_max_age::float))) AS percent_towards_emergency_autovac + FROM per_database_stats; + metrics: + - metric_name: ccp_transaction_wraparound_oldest_current_xid + value_column: oldest_current_xid + description: Oldest current transaction ID in cluster + static_attributes: + server: "localhost:5432" + - metric_name: ccp_transaction_wraparound_percent_towards_emergency_autovac + value_column: percent_towards_emergency_autovac + description: Percentage towards emergency autovacuum process starting + static_attributes: + server: "localhost:5432" + - metric_name: ccp_transaction_wraparound_percent_towards_wraparound + value_column: percent_towards_wraparound + description: Percentage towards transaction ID wraparound + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT last_5_min_size_bytes, + (SELECT COALESCE(sum(size),0) FROM pg_catalog.pg_ls_waldir()) AS total_size_bytes + FROM (SELECT COALESCE(sum(size),0) AS last_5_min_size_bytes FROM pg_catalog.pg_ls_waldir() WHERE modification > CURRENT_TIMESTAMP - '5 minutes'::interval) x; + metrics: + - metric_name: ccp_wal_activity_total_size_bytes + value_column: total_size_bytes + description: Current size in bytes of the WAL directory + static_attributes: + server: "localhost:5432" + + - sql: > + WITH monitor AS ( + SELECT + pg_get_userbyid(s.userid) AS role + , d.datname AS dbname + , s.queryid AS queryid + , btrim(replace(left(s.query, 40), '\n', '')) AS query + , s.calls + , s.total_exec_time AS total_exec_time + , s.max_exec_time AS max_exec_time_ms + , s.rows + , s.wal_records AS records + , s.wal_fpi AS fpi + , s.wal_bytes AS bytes + FROM public.pg_stat_statements s + JOIN pg_catalog.pg_database d ON d.oid = s.dbid + ) + SELECT role + , dbname + , queryid + , query + , max_exec_time_ms + , records + FROM monitor + ORDER BY 5 DESC + LIMIT 20; + metrics: + - metric_name: ccp_pg_stat_statements_top_max_exec_time_ms + value_column: max_exec_time_ms + value_type: double + description: Epoch time when stats were reset + attribute_columns: ["dbname", "query", "queryid", "role"] + static_attributes: + server: "localhost:5432" + + - sql: > + WITH monitor AS ( + SELECT + pg_get_userbyid(s.userid) AS role + , d.datname AS dbname + , s.queryid AS queryid + , btrim(replace(left(s.query, 40), '\n', '')) AS query + , s.calls + , s.total_exec_time AS total_exec_time_ms + , s.rows + , s.wal_records AS records + , s.wal_fpi AS fpi + , s.wal_bytes AS bytes + FROM public.pg_stat_statements s + JOIN pg_catalog.pg_database d ON d.oid = s.dbid + ) + SELECT role + , dbname + , queryid + , query + , total_exec_time_ms + , records + FROM monitor + ORDER BY 5 DESC + LIMIT 20; + metrics: + - metric_name: ccp_pg_stat_statements_top_total_exec_time_ms + value_column: total_exec_time_ms + value_type: double + description: Total time spent in the statement in milliseconds + attribute_columns: ["dbname", "query", "queryid", "role"] + static_attributes: + server: "localhost:5432" + + - sql: > + WITH monitor AS ( + SELECT + pg_get_userbyid(s.userid) AS role + , d.datname AS dbname + , s.queryid AS queryid + , btrim(replace(left(s.query, 40), '\n', '')) AS query + , s.calls + , s.total_exec_time AS total_exec_time + , s.max_exec_time AS max_exec_time + , s.mean_exec_time AS mean_exec_time + , s.rows + , s.wal_records AS records + , s.wal_fpi AS fpi + , s.wal_bytes AS bytes + FROM public.pg_stat_statements s + JOIN pg_catalog.pg_database d ON d.oid = s.dbid + ) + SELECT role + , dbname + , query + , queryid + , records + , fpi + , bytes + FROM monitor + ORDER BY bytes DESC + LIMIT 20; + metrics: + - metric_name: ccp_pg_stat_statements_top_wal_bytes + value_column: bytes + value_type: double + description: Total amount of WAL generated by the statement in bytes + attribute_columns: ["dbname", "query", "queryid", "role"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_pg_stat_statements_top_wal_fpi + value_column: fpi + value_type: double + description: Total number of WAL full page images generated by the statement + attribute_columns: ["dbname", "query", "queryid", "role"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_pg_stat_statements_top_wal_records + value_column: records + value_type: double + description: Total number of WAL records generated by the statement + attribute_columns: ["dbname", "query", "queryid", "role"] + static_attributes: + server: "localhost:5432" + + - sql: | + SELECT * FROM get_pgbackrest_info(); + metrics: + - metric_name: ccp_backrest_last_diff_backup_time_since_completion_seconds + description: Seconds since the last completed full or differential backup. Differential is always based off last full. + value_column: last_diff_backup + attribute_columns: ["repo"] + static_attributes: + server: "localhost:5432" + stanza: "db" + - metric_name: ccp_backrest_last_full_backup_time_since_completion_seconds + description: Seconds since the last completed full backup + value_column: last_full_backup + attribute_columns: ["repo"] + static_attributes: + server: "localhost:5432" + stanza: "db" + - metric_name: ccp_backrest_last_incr_backup_time_since_completion_seconds + description: | + Seconds since the last completed full, differential or incremental backup. + Incremental is always based off last full or differential. + value_column: last_incr_backup + attribute_columns: ["repo"] + static_attributes: + server: "localhost:5432" + stanza: "db" + - metric_name: ccp_backrest_last_info_backrest_repo_version + description: pgBackRest version number when this backup was performed + value_column: last_info_backrest_repo_version + attribute_columns: ["backup_type", "repo"] + static_attributes: + server: "localhost:5432" + stanza: "db" + - metric_name: ccp_backrest_last_info_backup_error + description: An error has been encountered in the backup. Check logs for more information. + value_column: last_info_backup_error + attribute_columns: ["backup_type", "repo"] + static_attributes: + server: "localhost:5432" + stanza: "db" + - metric_name: ccp_backrest_last_info_backup_runtime_seconds + description: Total runtime in seconds of this backup + value_column: backup_runtime_seconds + attribute_columns: ["backup_type", "repo"] + static_attributes: + server: "localhost:5432" + stanza: "db" + - metric_name: ccp_backrest_last_info_repo_backup_size_bytes + description: Actual size of only this individual backup in the pgbackrest repository + value_column: repo_backup_size_bytes + attribute_columns: ["backup_type", "repo"] + static_attributes: + server: "localhost:5432" + stanza: "db" + - metric_name: ccp_backrest_oldest_full_backup_time_seconds + description: Seconds since the oldest completed full backup + value_column: oldest_full_backup + attribute_columns: ["repo"] + static_attributes: + server: "localhost:5432" + + - sql: > + SELECT s.datname AS dbname + , s.xact_commit + , s.xact_rollback + , s.blks_read + , s.blks_hit + , s.tup_returned + , s.tup_fetched + , s.tup_inserted + , s.tup_updated + , s.tup_deleted + , s.conflicts + , s.temp_files + , s.temp_bytes + , s.deadlocks + FROM pg_catalog.pg_stat_database s + JOIN pg_catalog.pg_database d ON d.datname = s.datname + WHERE d.datistemplate = false; + metrics: + - metric_name: ccp_stat_database_blks_hit + value_column: blks_hit + description: Number of times disk blocks were found already in the buffer cache, so that a read was not necessary + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_database_blks_read + value_column: blks_read + description: Number of disk blocks read in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_database_conflicts + value_column: conflicts + description: Number of queries canceled due to conflicts with recovery in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_database_deadlocks + value_column: deadlocks + description: Number of deadlocks detected in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_database_temp_bytes + value_column: temp_bytes + description: Total amount of data written to temporary files by queries in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + - metric_name: ccp_stat_database_temp_files + value_column: temp_files + description: Number of rows deleted by queries in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + + - metric_name: ccp_stat_database_tup_deleted + value_column: tup_deleted + description: Number of rows deleted by queries in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + + - metric_name: ccp_stat_database_tup_fetched + value_column: tup_fetched + description: Number of rows fetched by queries in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + + - metric_name: ccp_stat_database_tup_inserted + value_column: tup_inserted + description: Number of rows inserted by queries in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + + - metric_name: ccp_stat_database_tup_returned + value_column: tup_returned + description: Number of rows returned by queries in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + + - metric_name: ccp_stat_database_tup_updated + value_column: tup_updated + description: Number of rows updated by queries in this database + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + + - metric_name: ccp_stat_database_xact_commit + value_column: xact_commit + description: Number of transactions in this database that have been committed + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" + + - metric_name: ccp_stat_database_xact_rollback + value_column: xact_rollback + description: Number of transactions in this database that have been rolled back + attribute_columns: ["dbname"] + static_attributes: + server: "localhost:5432" diff --git a/internal/collector/postgres_logs_transforms.yaml b/internal/collector/postgres_logs_transforms.yaml new file mode 100644 index 0000000000..c58f1a1a7b --- /dev/null +++ b/internal/collector/postgres_logs_transforms.yaml @@ -0,0 +1,208 @@ +# This list of transform statements configures an OTel Transform Processor to +# parse PostgreSQL logs. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/transformprocessor#readme +# https://www.postgresql.org/docs/current/runtime-config-logging.html + + +# TODO(postgres-14): We can stop parsing CSV logs when 14 is EOL. +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/contexts/ottllog#readme +- conditions: + - log.body["format"] == "csv" + statements: + # https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/ottlfuncs#parsecsv + - set(log.cache, ParseCSV(log.body["original"], log.body["headers"], delimiter=",", mode="strict")) + + # Extract the optional "remote_port" value from the "connection_from" field. It is either: + # 1. a Unix socket starting with "[local]:" or + # 2. a TCP socket ending with a colon U+003A and a port. + # + # https://git.postgresql.org/gitweb/?p=postgresql.git;hb=REL_10_0;f=src/backend/utils/error/elog.c#l2701 + # https://git.postgresql.org/gitweb/?p=postgresql.git;hb=REL_10_0;f=src/common/ip.c#l227 + # + # https://git.postgresql.org/gitweb/?p=postgresql.git;hb=REL_17_0;f=src/backend/utils/error/csvlog.c#l108 + # https://git.postgresql.org/gitweb/?p=postgresql.git;hb=REL_17_0;f=src/common/ip.c#l224 + - >- + merge_maps(log.cache, + ExtractPatterns(log.cache["connection_from"], "(?:^[[]local[]]:(?.+)|:(?[^:]+))$"), + "insert") + where Len(log.cache["connection_from"]) > 0 + + # When there is a "remote_port" value, everything before it is the "remote_host" value. + - >- + set(log.cache["remote_host"], + Substring(log.cache["connection_from"], 0, Len(log.cache["connection_from"]) - Len(log.cache["remote_port"]) - 1)) + where Len(log.cache["connection_from"]) > 0 and IsString(log.cache["remote_port"]) + + # When there is still no "remote_host" value, copy the "connection_from" value, if any. + - >- + set(log.cache["remote_host"], log.cache["connection_from"]) + where Len(log.cache["connection_from"]) > 0 and not IsString(log.cache["remote_host"]) + + # Extract the values encoded in the "location" field. + # + # https://git.postgresql.org/gitweb/?p=postgresql.git;hb=REL_10_0;f=src/backend/utils/error/elog.c#l2805 + # https://git.postgresql.org/gitweb/?p=postgresql.git;hb=REL_17_0;f=src/backend/utils/error/csvlog.c#l207 + - >- + merge_maps(log.cache, + ExtractPatterns(log.cache["location"], "^(?:(?[^,]+), )?(?[^:]+):(?\\d+)$"), + "insert") + where Len(log.cache["location"]) > 0 + + # These values are numeric in JSON logs. + - >- + set(log.cache["cursor_position"], Double(log.cache["cursor_position"])) + where IsMatch(log.cache["cursor_position"], "^[0-9.]+$") + - >- + set(log.cache["file_line_num"], Double(log.cache["file_line_num"])) + where IsMatch(log.cache["file_line_num"], "^[0-9.]+$") + - >- + set(log.cache["internal_position"], Double(log.cache["internal_position"])) + where IsMatch(log.cache["internal_position"], "^[0-9.]+$") + - >- + set(log.cache["leader_pid"], Double(log.cache["leader_pid"])) + where IsMatch(log.cache["leader_pid"], "^[0-9.]+$") + - >- + set(log.cache["line_num"], Double(log.cache["line_num"])) + where IsMatch(log.cache["line_num"], "^[0-9.]+$") + - >- + set(log.cache["pid"], Double(log.cache["pid"])) + where IsMatch(log.cache["pid"], "^[0-9.]+$") + - >- + set(log.cache["query_id"], Double(log.cache["query_id"])) + where IsMatch(log.cache["query_id"], "^[0-9.]+$") + - >- + set(log.cache["remote_port"], Double(log.cache["remote_port"])) + where IsMatch(log.cache["remote_port"], "^[0-9.]+$") + + # Pass the results to the next set of statements. + - set(log.body["parsed"], log.cache) + + +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/contexts/ottllog#readme +- statements: + - set(instrumentation_scope.name, "postgres") + - set(instrumentation_scope.version, resource.attributes["db.version"]) + + # TODO(postgres-14): We can stop parsing CSV logs when 14 is EOL. + - set(log.cache, log.body["parsed"]) where log.body["format"] == "csv" + + # https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/ottlfuncs#parsejson + - set(log.cache, ParseJSON(log.body["original"])) where log.body["format"] == "json" + + # The log severity is in the "error_severity" field. + # https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitytext + - set(log.severity_text, log.cache["error_severity"]) + + # Map severity text to OpenTelemetry severity levels. + # Postgres has levels beyond the typical ones: + # - Multiple DEBUG levels, with DEBUG5 being the most detailed. + # - NOTICE is more severe than INFO. + # - PANIC is more severe than FATAL. + # + # https://www.postgresql.org/docs/current/runtime-config-logging.html#RUNTIME-CONFIG-SEVERITY-LEVELS + # https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber + # https://opentelemetry.io/docs/specs/otel/logs/data-model-appendix/#appendix-b-severitynumber-example-mappings + # https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/contexts/ottllog#enums + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == "DEBUG5" + - set(log.severity_number, SEVERITY_NUMBER_TRACE2) where log.severity_text == "DEBUG4" + - set(log.severity_number, SEVERITY_NUMBER_TRACE3) where log.severity_text == "DEBUG3" + - set(log.severity_number, SEVERITY_NUMBER_TRACE4) where log.severity_text == "DEBUG2" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == "DEBUG1" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == "INFO" or log.severity_text == "LOG" + - set(log.severity_number, SEVERITY_NUMBER_INFO2) where log.severity_text == "NOTICE" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == "FATAL" + - set(log.severity_number, SEVERITY_NUMBER_FATAL2) where log.severity_text == "PANIC" + + # Parse the "timestamp" field into the record timestamp. + # The format is neither RFC 3339 nor ISO 8601: + # + # The date and time are separated by a single space U+0020, + # followed by a dot U+002E, milliseconds, another space U+0020, + # then a timezone abbreviation. + # + # https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/ottlfuncs#time + # https://git.postgresql.org/gitweb/?p=postgresql.git;hb=REL_10_0;f=src/backend/utils/error/elog.c#l2246 + # https://git.postgresql.org/gitweb/?p=postgresql.git;hb=REL_17_0;f=src/backend/utils/error/elog.c#l2671 + - set(log.time, Time(log.cache["timestamp"], "%F %T.%L %Z")) where IsString(log.cache["timestamp"]) + + # Rename fields emitted by Postgres to align with OpenTelemetry semantic conventions. + # + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs#readme + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/database#readme + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/database/postgresql.md + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/database/sql.md + - set(instrumentation_scope.schema_url, "https://opentelemetry.io/schemas/1.29.0") + - set(resource.attributes["db.system"], "postgresql") + + # Keep the unparsed log record in a standard attribute, + # and replace the log record log.body with the parsed fields. + # + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/general/logs.md + - set(log.attributes["log.record.original"], log.body["original"]) + - set(log.body, log.cache) + + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/attributes-registry/client.md + - set(log.attributes["client.address"], log.body["remote_host"]) where IsString(log.body["remote_host"]) + - set(log.attributes["client.port"], Int(log.body["remote_port"])) where IsDouble(log.body["remote_port"]) + + # These values are populated when the "log_error_verbosity" parameter is VERBOSE. + # + # https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-ERROR-VERBOSITY + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/attributes-registry/code.md + - set(log.attributes["code.filepath"], log.body["file_name"]) where IsString(log.body["file_name"]) + - set(log.attributes["code.function"], log.body["func_name"]) where IsString(log.body["func_name"]) + - set(log.attributes["code.lineno"], Int(log.body["file_line_num"])) where IsDouble(log.body["file_line_num"]) + + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/attributes-registry/db.md + - set(log.attributes["db.namespace"], log.body["dbname"]) where IsString(log.body["dbname"]) + - set(log.attributes["db.response.status_code"], log.body["state_code"]) where IsString(log.body["state_code"]) + + # Postgres is multiprocess so some client/backend details align here. + # + # The "session_start" value is formatted as "%F %T UTC", but "process.creation.time" should be ISO 8601. + # + # https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/backend/utils/error/elog.c;hb=REL_10_0#l2256 + # https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/backend/utils/error/elog.c;hb=REL_17_0#l2697 + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/attributes-registry/process.md + - >- + set(log.attributes["process.creation.time"], Concat([ + Substring(log.body["session_start"], 0, 10), "T", + Substring(log.body["session_start"], 11, 8), "Z"], "")) + where IsMatch(log.body["session_start"], "^[^ ]{10} [^ ]{8} UTC$") + - >- + set(log.attributes["process.pid"], Int(log.body["pid"])) + where IsDouble(log.body["pid"]) + - >- + set(log.attributes["process.title"], log.body["ps"]) + where IsString(log.body["ps"]) + + # https://github.com/open-telemetry/semantic-conventions/blob/v1.29.0/docs/attributes-registry/user.md + - >- + set(log.attributes["user.name"], log.body["user"]) + where IsString(log.body["user"]) + + +# Look for and parse the CSV of a pgAudit message. +# +# https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/contexts/ottllog#readme +# https://github.com/pgaudit/pgaudit#format +- conditions: + # Messages from pgAudit have always been prefixed with "AUDIT:", but that + # could change in the future. + # + # https://github.com/pgaudit/pgaudit/blame/17.0/pgaudit.c#L876 + # TODO(postgres-18): Check this prefix and update the URL above. + - >- + Len(log.body["message"]) > 7 and Substring(log.body["message"], 0, 7) == "AUDIT: " + statements: + # https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/pkg/ottl/ottlfuncs#parsecsv + - >- + set(log.body["pgaudit"], ParseCSV(Substring(log.body["message"], 7, Len(log.body["message"]) - 7), + "audit_type,statement_id,substatement_id,class,command,object_type,object_name,statement,parameter", + delimiter=",", mode="strict")) + - >- + set(instrumentation_scope.name, "pgaudit") + where Len(log.body["pgaudit"]) > 0 diff --git a/internal/collector/postgres_metrics.go b/internal/collector/postgres_metrics.go new file mode 100644 index 0000000000..f3d5371cc6 --- /dev/null +++ b/internal/collector/postgres_metrics.go @@ -0,0 +1,316 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "slices" + "strconv" + + "github.com/crunchydata/postgres-operator/internal/logging" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// https://pkg.go.dev/embed +// +//go:embed "generated/postgres_5s_metrics.json" +var fiveSecondMetrics json.RawMessage + +//go:embed "generated/postgres_5m_per_db_metrics.json" +var fiveMinutePerDBMetrics json.RawMessage + +//go:embed "generated/postgres_5m_metrics.json" +var fiveMinuteMetrics json.RawMessage + +//go:embed "generated/gte_pg17_fast_metrics.json" +var gtePG17Fast json.RawMessage + +//go:embed "generated/lt_pg17_fast_metrics.json" +var ltPG17Fast json.RawMessage + +//go:embed "generated/eq_pg16_fast_metrics.json" +var eqPG16Fast json.RawMessage + +//go:embed "generated/lt_pg16_fast_metrics.json" +var ltPG16Fast json.RawMessage + +type queryMetrics struct { + Metrics []*metric `json:"metrics"` + Query string `json:"sql"` +} + +type metric struct { + Aggregation string `json:"aggregation,omitempty"` + AttributeColumns []string `json:"attribute_columns,omitempty"` + DataType string `json:"data_type,omitempty"` + Description string `json:"description,omitempty"` + MetricName string `json:"metric_name"` + Monotonic bool `json:"monotonic,omitempty"` + StartTsColumn string `json:"start_ts_column,omitempty"` + StaticAttributes map[string]string `json:"static_attributes,omitempty"` + TsColumn string `json:"ts_column,omitempty"` + Unit string `json:"unit,omitempty"` + ValueColumn string `json:"value_column"` + ValueType string `json:"value_type,omitempty"` +} + +func EnablePostgresMetrics(ctx context.Context, inCluster *v1beta1.PostgresCluster, config *Config) { + if OpenTelemetryMetricsEnabled(ctx, inCluster) { + log := logging.FromContext(ctx) + var err error + + // We must create a copy of the fiveSecondMetrics variable, otherwise we + // will continually append to it and blow up our ConfigMap + fiveSecondMetricsClone := slices.Clone(fiveSecondMetrics) + fiveMinuteMetricsClone := slices.Clone(fiveMinuteMetrics) + fiveMinutePerDBMetricsClone := slices.Clone(fiveMinutePerDBMetrics) + + if inCluster.Spec.PostgresVersion >= 17 { + fiveSecondMetricsClone, err = appendToJSONArray(fiveSecondMetricsClone, gtePG17Fast) + if err != nil { + log.Error(err, "error compiling metrics for postgres 17 and greater") + } + } else { + fiveSecondMetricsClone, err = appendToJSONArray(fiveSecondMetricsClone, ltPG17Fast) + if err != nil { + log.Error(err, "error compiling metrics for postgres versions less than 17") + } + } + + if inCluster.Spec.PostgresVersion == 16 { + fiveSecondMetricsClone, err = appendToJSONArray(fiveSecondMetricsClone, eqPG16Fast) + } + if err != nil { + log.Error(err, "error compiling metrics for postgres 16") + } + + if inCluster.Spec.PostgresVersion < 16 { + fiveSecondMetricsClone, err = appendToJSONArray(fiveSecondMetricsClone, ltPG16Fast) + if err != nil { + log.Error(err, "error compiling fast metrics for postgres versions less than 16") + } + } + + // Remove any queries that user has specified in the spec + if inCluster.Spec.Instrumentation != nil && + inCluster.Spec.Instrumentation.Metrics != nil && + inCluster.Spec.Instrumentation.Metrics.CustomQueries != nil && + inCluster.Spec.Instrumentation.Metrics.CustomQueries.Remove != nil { + + // Convert json to array of queryMetrics objects + var fiveSecondMetricsArr []queryMetrics + err := json.Unmarshal(fiveSecondMetricsClone, &fiveSecondMetricsArr) + if err != nil { + log.Error(err, "error compiling five second postgres metrics") + } + + // Remove any specified metrics from the five second metrics + fiveSecondMetricsArr = removeMetricsFromQueries( + inCluster.Spec.Instrumentation.Metrics.CustomQueries.Remove, fiveSecondMetricsArr) + + // Convert json to array of queryMetrics objects + var fiveMinuteMetricsArr []queryMetrics + err = json.Unmarshal(fiveMinuteMetricsClone, &fiveMinuteMetricsArr) + if err != nil { + log.Error(err, "error compiling five minute postgres metrics") + } + + // Remove any specified metrics from the five minute metrics + fiveMinuteMetricsArr = removeMetricsFromQueries( + inCluster.Spec.Instrumentation.Metrics.CustomQueries.Remove, fiveMinuteMetricsArr) + + // Convert json to array of queryMetrics objects + var fiveMinutePerDBMetricsArr []queryMetrics + err = json.Unmarshal(fiveMinutePerDBMetricsClone, &fiveMinutePerDBMetricsArr) + if err != nil { + log.Error(err, "error compiling per-db postgres metrics") + } + + // Remove any specified metrics from the five minute per-db metrics + fiveMinutePerDBMetricsArr = removeMetricsFromQueries( + inCluster.Spec.Instrumentation.Metrics.CustomQueries.Remove, fiveMinutePerDBMetricsArr) + + // Convert back to json data + // The error return value can be ignored as the errchkjson linter + // deems the []queryMetrics to be a safe argument: + // https://github.com/breml/errchkjson + fiveSecondMetricsClone, _ = json.Marshal(fiveSecondMetricsArr) + fiveMinuteMetricsClone, _ = json.Marshal(fiveMinuteMetricsArr) + fiveMinutePerDBMetricsClone, _ = json.Marshal(fiveMinutePerDBMetricsArr) + } + + // Add Prometheus exporter + config.Exporters[Prometheus] = map[string]any{ + "endpoint": "0.0.0.0:" + strconv.Itoa(PrometheusPort), + } + + config.Receivers[FiveSecondSqlQuery] = map[string]any{ + "driver": "postgres", + "datasource": fmt.Sprintf( + `host=localhost dbname=postgres port=5432 user=%s password=${env:PGPASSWORD}`, + MonitoringUser), + "collection_interval": "5s", + // Give Postgres time to finish setup. + "initial_delay": "15s", + "queries": slices.Clone(fiveSecondMetricsClone), + } + + config.Receivers[FiveMinuteSqlQuery] = map[string]any{ + "driver": "postgres", + "datasource": fmt.Sprintf( + `host=localhost dbname=postgres port=5432 user=%s password=${env:PGPASSWORD}`, + MonitoringUser), + "collection_interval": "300s", + // Give Postgres time to finish setup. + "initial_delay": "15s", + "queries": slices.Clone(fiveMinuteMetricsClone), + } + + // If there are exporters to be added to the metrics pipelines defined + // in the spec, add them to the pipeline. + exporters := []ComponentID{Prometheus} + if inCluster.Spec.Instrumentation.Metrics != nil && + inCluster.Spec.Instrumentation.Metrics.Exporters != nil { + exporters = append(exporters, inCluster.Spec.Instrumentation.Metrics.Exporters...) + } + + // Add Metrics Pipeline + config.Pipelines[PostgresMetrics] = Pipeline{ + Receivers: []ComponentID{FiveSecondSqlQuery, FiveMinuteSqlQuery}, + Processors: []ComponentID{ + SubSecondBatchProcessor, + CompactingProcessor, + }, + Exporters: exporters, + } + + // Add custom queries and per-db metrics if they are defined in the spec + if inCluster.Spec.Instrumentation != nil && + inCluster.Spec.Instrumentation.Metrics != nil { + + if inCluster.Spec.Instrumentation.Metrics.CustomQueries != nil && + inCluster.Spec.Instrumentation.Metrics.CustomQueries.Add != nil { + + for _, querySet := range inCluster.Spec.Instrumentation.Metrics.CustomQueries.Add { + // Create a receiver for the query set + + dbs := []string{"postgres"} + if len(querySet.Databases) != 0 { + dbs = querySet.Databases + } + for _, db := range dbs { + receiverName := fmt.Sprintf( + "sqlquery/%s-%s", querySet.Name, db) + config.Receivers[receiverName] = map[string]any{ + "driver": "postgres", + "datasource": fmt.Sprintf( + `host=localhost dbname=%s port=5432 user=%s password=${env:PGPASSWORD}`, + db, + MonitoringUser), + "collection_interval": querySet.CollectionInterval, + // Give Postgres time to finish setup. + "initial_delay": "15s", + "queries": "${file:/etc/otel-collector/" + + querySet.Name + "/" + querySet.Queries.Key + "}", + } + + // Add the receiver to the pipeline + pipeline := config.Pipelines[PostgresMetrics] + pipeline.Receivers = append(pipeline.Receivers, receiverName) + config.Pipelines[PostgresMetrics] = pipeline + } + } + } + if inCluster.Spec.Instrumentation.Metrics.PerDBMetricTargets != nil { + + for _, db := range inCluster.Spec.Instrumentation.Metrics.PerDBMetricTargets { + // Create a receiver for the query set for the db + receiverName := "sqlquery/" + db + config.Receivers[receiverName] = map[string]any{ + "driver": "postgres", + "datasource": fmt.Sprintf( + `host=localhost dbname=%s port=5432 user=%s password=${env:PGPASSWORD}`, + db, + MonitoringUser), + "collection_interval": "5m", + // Give Postgres time to finish setup. + "initial_delay": "15s", + "queries": slices.Clone(fiveMinutePerDBMetricsClone), + } + + // Add the receiver to the pipeline + pipeline := config.Pipelines[PostgresMetrics] + pipeline.Receivers = append(pipeline.Receivers, receiverName) + config.Pipelines[PostgresMetrics] = pipeline + } + } + } + } +} + +// appendToJSONArray appends elements of a json.RawMessage containing an array +// to another json.RawMessage containing an array. +func appendToJSONArray(a1, a2 json.RawMessage) (json.RawMessage, error) { + var slc1 []json.RawMessage + if err := json.Unmarshal(a1, &slc1); err != nil { + return nil, err + } + + var slc2 []json.RawMessage + if err := json.Unmarshal(a2, &slc2); err != nil { + return nil, err + } + + mergedSlice := append(slc1, slc2...) + + merged, err := json.Marshal(mergedSlice) + if err != nil { + return nil, err + } + + return merged, nil +} + +func removeMetricsFromQueries(metricsToRemove []string, + queryMetricsArr []queryMetrics, +) []queryMetrics { + // Iterate over the metrics that should be removed +Outer: + for _, metricToRemove := range metricsToRemove { + // Iterate over array of query/metrics objects + for j, queryAndMetrics := range queryMetricsArr { + // Iterate over the metrics array + metricsArr := queryAndMetrics.Metrics + for k, metric := range metricsArr { + // Check to see if the metric_name matches the metricToRemove + if metric.MetricName == metricToRemove { + // Remove the metric. Since there won't ever be any + // duplicates, we will be exiting this loop early and + // therefore don't care about the order of the metrics + // array. + metricsArr[len(metricsArr)-1], metricsArr[k] = nil, metricsArr[len(metricsArr)-1] + metricsArr = metricsArr[:len(metricsArr)-1] + queryMetricsArr[j].Metrics = metricsArr + + // If the metrics array is empty, remove the query/metrics + // map entirely. Again, we don't care about order. + if len(metricsArr) == 0 { + queryMetricsArr[j] = queryMetricsArr[len(queryMetricsArr)-1] + queryMetricsArr = queryMetricsArr[:len(queryMetricsArr)-1] + } + + // We found and deleted the metric, so we can continue + // to the next iteration of the Outer loop. + continue Outer + } + } + } + } + + return queryMetricsArr +} diff --git a/internal/collector/postgres_metrics_test.go b/internal/collector/postgres_metrics_test.go new file mode 100644 index 0000000000..8a22f42b52 --- /dev/null +++ b/internal/collector/postgres_metrics_test.go @@ -0,0 +1,121 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "encoding/json" + "testing" + + "gotest.tools/v3/assert" +) + +func TestRemoveMetricsFromQueries(t *testing.T) { + // Convert json to map + var fiveMinuteMetricsArr []queryMetrics + err := json.Unmarshal(fiveMinuteMetrics, &fiveMinuteMetricsArr) + assert.NilError(t, err) + + assert.Equal(t, len(fiveMinuteMetricsArr), 3) + newArr := removeMetricsFromQueries([]string{"ccp_database_size_bytes"}, fiveMinuteMetricsArr) + assert.Equal(t, len(newArr), 2) + + t.Run("DeleteOneMetric", func(t *testing.T) { + sqlMetricsData := `[ + { + "metrics": [ + { + "description": "Count of sequences that have reached greater than or equal to 75% of their max available numbers.\nFunction monitor.sequence_status() can provide more details if run directly on system.\n", + "metric_name": "ccp_sequence_exhaustion_count", + "static_attributes": { "server": "localhost:5432" }, + "value_column": "count" + } + ], + "sql": "SELECT count(*) AS count FROM (\n SELECT CEIL((s.max_value-min_value::NUMERIC+1)/s.increment_by::NUMERIC) AS slots\n , CEIL((COALESCE(s.last_value,s.min_value)-s.min_value::NUMERIC+1)/s.increment_by::NUMERIC) AS used\n FROM pg_catalog.pg_sequences s\n) x WHERE (ROUND(used/slots*100)::int) \u003e 75;\n" + }, + { + "metrics": [ + { + "attribute_columns": ["dbname"], + "description": "Number of times disk blocks were found already in the buffer cache, so that a read was not necessary", + "metric_name": "ccp_stat_database_blks_hit", + "static_attributes": { "server": "localhost:5432" }, + "value_column": "blks_hit" + }, + { + "attribute_columns": ["dbname"], + "description": "Number of disk blocks read in this database", + "metric_name": "ccp_stat_database_blks_read", + "static_attributes": { "server": "localhost:5432" }, + "value_column": "blks_read" + } + ], + "sql": "SELECT s.datname AS dbname , s.xact_commit , s.xact_rollback , s.blks_read , s.blks_hit , s.tup_returned , s.tup_fetched , s.tup_inserted , s.tup_updated , s.tup_deleted , s.conflicts , s.temp_files , s.temp_bytes , s.deadlocks FROM pg_catalog.pg_stat_database s JOIN pg_catalog.pg_database d ON d.datname = s.datname WHERE d.datistemplate = false;\n" + } +]` + var sqlMetricsArr []queryMetrics + err := json.Unmarshal([]byte(sqlMetricsData), &sqlMetricsArr) + assert.NilError(t, err) + + assert.Equal(t, len(sqlMetricsArr), 2) + metricsArr := sqlMetricsArr[1].Metrics + assert.Equal(t, len(metricsArr), 2) + + refinedSqlMetricsArr := removeMetricsFromQueries([]string{"ccp_stat_database_blks_hit"}, sqlMetricsArr) + assert.Equal(t, len(refinedSqlMetricsArr), 2) + metricsArr = refinedSqlMetricsArr[1].Metrics + assert.Equal(t, len(metricsArr), 1) + remainingMetric := metricsArr[0] + assert.Equal(t, remainingMetric.MetricName, "ccp_stat_database_blks_read") + }) + + t.Run("DeleteQueryMetricSet", func(t *testing.T) { + sqlMetricsData := `[ + { + "metrics": [ + { + "description": "Count of sequences that have reached greater than or equal to 75% of their max available numbers.\nFunction monitor.sequence_status() can provide more details if run directly on system.\n", + "metric_name": "ccp_sequence_exhaustion_count", + "static_attributes": { "server": "localhost:5432" }, + "value_column": "count" + } + ], + "sql": "SELECT count(*) AS count FROM (\n SELECT CEIL((s.max_value-min_value::NUMERIC+1)/s.increment_by::NUMERIC) AS slots\n , CEIL((COALESCE(s.last_value,s.min_value)-s.min_value::NUMERIC+1)/s.increment_by::NUMERIC) AS used\n FROM pg_catalog.pg_sequences s\n) x WHERE (ROUND(used/slots*100)::int) \u003e 75;\n" + }, + { + "metrics": [ + { + "attribute_columns": ["dbname"], + "description": "Number of times disk blocks were found already in the buffer cache, so that a read was not necessary", + "metric_name": "ccp_stat_database_blks_hit", + "static_attributes": { "server": "localhost:5432" }, + "value_column": "blks_hit" + }, + { + "attribute_columns": ["dbname"], + "description": "Number of disk blocks read in this database", + "metric_name": "ccp_stat_database_blks_read", + "static_attributes": { "server": "localhost:5432" }, + "value_column": "blks_read" + } + ], + "sql": "SELECT s.datname AS dbname , s.xact_commit , s.xact_rollback , s.blks_read , s.blks_hit , s.tup_returned , s.tup_fetched , s.tup_inserted , s.tup_updated , s.tup_deleted , s.conflicts , s.temp_files , s.temp_bytes , s.deadlocks FROM pg_catalog.pg_stat_database s JOIN pg_catalog.pg_database d ON d.datname = s.datname WHERE d.datistemplate = false;\n" + } +]` + var sqlMetricsArr []queryMetrics + err := json.Unmarshal([]byte(sqlMetricsData), &sqlMetricsArr) + assert.NilError(t, err) + + assert.Equal(t, len(sqlMetricsArr), 2) + metricsArr := sqlMetricsArr[1].Metrics + assert.Equal(t, len(metricsArr), 2) + + refinedSqlMetricsArr := removeMetricsFromQueries([]string{"ccp_stat_database_blks_hit", + "ccp_stat_database_blks_read"}, sqlMetricsArr) + assert.Equal(t, len(refinedSqlMetricsArr), 1) + metricsArr = sqlMetricsArr[0].Metrics + assert.Equal(t, len(metricsArr), 1) + }) + +} diff --git a/internal/collector/postgres_test.go b/internal/collector/postgres_test.go new file mode 100644 index 0000000000..89f5f52255 --- /dev/null +++ b/internal/collector/postgres_test.go @@ -0,0 +1,683 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/postgres" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestEnablePostgresLogging(t *testing.T) { + t.Run("EmptyInstrumentationSpec", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.PostgresVersion = 99 + require.UnmarshalInto(t, &cluster.Spec, `{ + instrumentation: {} + }`) + + config := NewConfig(nil) + params := postgres.NewParameterSet() + + EnablePostgresLogging(ctx, cluster, config, params) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed +extensions: + file_storage/pgbackrest_logs: + create_directory: false + directory: /pgdata/pgbackrest/log/receiver + fsync: true + file_storage/postgres_logs: + create_directory: true + directory: /pgdata/logs/postgres/receiver + fsync: true +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/pgbackrest: + attributes: + - action: insert + key: k8s.container.name + value: database + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: pgbackrest + resource/postgres: + attributes: + - action: insert + key: k8s.container.name + value: database + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: postgres + - action: insert + key: db.system + value: postgresql + - action: insert + key: db.version + value: "99" + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/pgbackrest_logs: + log_statements: + - statements: + - set(instrumentation_scope.name, "pgbackrest") + - set(instrumentation_scope.schema_url, "https://opentelemetry.io/schemas/1.29.0") + - 'merge_maps(log.cache, ExtractPatterns(log.body, "^(?\\d{4}-\\d{2}-\\d{2} + \\d{2}:\\d{2}:\\d{2}\\.\\d{3}) (?P\\d{2,3})\\s*(?\\S*): + (?(?s).*)$"), "insert") where Len(log.body) > 0' + - set(log.severity_text, log.cache["error_severity"]) where IsString(log.cache["error_severity"]) + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == + "TRACE" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG2) where log.severity_text == + "DETAIL" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARN" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.time, Time(log.cache["timestamp"], "%Y-%m-%d %H:%M:%S.%L")) where + IsString(log.cache["timestamp"]) + - set(log.attributes["process.pid"], log.cache["process_id"]) + - set(log.attributes["log.record.original"], log.body) + - set(log.body, log.cache["message"]) + transform/postgres_logs: + log_statements: + - conditions: + - log.body["format"] == "csv" + statements: + - set(log.cache, ParseCSV(log.body["original"], log.body["headers"], delimiter=",", + mode="strict")) + - merge_maps(log.cache, ExtractPatterns(log.cache["connection_from"], "(?:^[[]local[]]:(?.+)|:(?[^:]+))$"), + "insert") where Len(log.cache["connection_from"]) > 0 + - set(log.cache["remote_host"], Substring(log.cache["connection_from"], 0, Len(log.cache["connection_from"]) + - Len(log.cache["remote_port"]) - 1)) where Len(log.cache["connection_from"]) + > 0 and IsString(log.cache["remote_port"]) + - set(log.cache["remote_host"], log.cache["connection_from"]) where Len(log.cache["connection_from"]) + > 0 and not IsString(log.cache["remote_host"]) + - merge_maps(log.cache, ExtractPatterns(log.cache["location"], "^(?:(?[^,]+), + )?(?[^:]+):(?\\d+)$"), "insert") where Len(log.cache["location"]) + > 0 + - set(log.cache["cursor_position"], Double(log.cache["cursor_position"])) where + IsMatch(log.cache["cursor_position"], "^[0-9.]+$") + - set(log.cache["file_line_num"], Double(log.cache["file_line_num"])) where + IsMatch(log.cache["file_line_num"], "^[0-9.]+$") + - set(log.cache["internal_position"], Double(log.cache["internal_position"])) + where IsMatch(log.cache["internal_position"], "^[0-9.]+$") + - set(log.cache["leader_pid"], Double(log.cache["leader_pid"])) where IsMatch(log.cache["leader_pid"], + "^[0-9.]+$") + - set(log.cache["line_num"], Double(log.cache["line_num"])) where IsMatch(log.cache["line_num"], + "^[0-9.]+$") + - set(log.cache["pid"], Double(log.cache["pid"])) where IsMatch(log.cache["pid"], + "^[0-9.]+$") + - set(log.cache["query_id"], Double(log.cache["query_id"])) where IsMatch(log.cache["query_id"], + "^[0-9.]+$") + - set(log.cache["remote_port"], Double(log.cache["remote_port"])) where IsMatch(log.cache["remote_port"], + "^[0-9.]+$") + - set(log.body["parsed"], log.cache) + - statements: + - set(instrumentation_scope.name, "postgres") + - set(instrumentation_scope.version, resource.attributes["db.version"]) + - set(log.cache, log.body["parsed"]) where log.body["format"] == "csv" + - set(log.cache, ParseJSON(log.body["original"])) where log.body["format"] == + "json" + - set(log.severity_text, log.cache["error_severity"]) + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == + "DEBUG5" + - set(log.severity_number, SEVERITY_NUMBER_TRACE2) where log.severity_text == + "DEBUG4" + - set(log.severity_number, SEVERITY_NUMBER_TRACE3) where log.severity_text == + "DEBUG3" + - set(log.severity_number, SEVERITY_NUMBER_TRACE4) where log.severity_text == + "DEBUG2" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG1" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" or log.severity_text == "LOG" + - set(log.severity_number, SEVERITY_NUMBER_INFO2) where log.severity_text == + "NOTICE" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == + "FATAL" + - set(log.severity_number, SEVERITY_NUMBER_FATAL2) where log.severity_text == + "PANIC" + - set(log.time, Time(log.cache["timestamp"], "%F %T.%L %Z")) where IsString(log.cache["timestamp"]) + - set(instrumentation_scope.schema_url, "https://opentelemetry.io/schemas/1.29.0") + - set(resource.attributes["db.system"], "postgresql") + - set(log.attributes["log.record.original"], log.body["original"]) + - set(log.body, log.cache) + - set(log.attributes["client.address"], log.body["remote_host"]) where IsString(log.body["remote_host"]) + - set(log.attributes["client.port"], Int(log.body["remote_port"])) where IsDouble(log.body["remote_port"]) + - set(log.attributes["code.filepath"], log.body["file_name"]) where IsString(log.body["file_name"]) + - set(log.attributes["code.function"], log.body["func_name"]) where IsString(log.body["func_name"]) + - set(log.attributes["code.lineno"], Int(log.body["file_line_num"])) where IsDouble(log.body["file_line_num"]) + - set(log.attributes["db.namespace"], log.body["dbname"]) where IsString(log.body["dbname"]) + - set(log.attributes["db.response.status_code"], log.body["state_code"]) where + IsString(log.body["state_code"]) + - set(log.attributes["process.creation.time"], Concat([ Substring(log.body["session_start"], + 0, 10), "T", Substring(log.body["session_start"], 11, 8), "Z"], "")) where + IsMatch(log.body["session_start"], "^[^ ]{10} [^ ]{8} UTC$") + - set(log.attributes["process.pid"], Int(log.body["pid"])) where IsDouble(log.body["pid"]) + - set(log.attributes["process.title"], log.body["ps"]) where IsString(log.body["ps"]) + - set(log.attributes["user.name"], log.body["user"]) where IsString(log.body["user"]) + - conditions: + - 'Len(log.body["message"]) > 7 and Substring(log.body["message"], 0, 7) == + "AUDIT: "' + statements: + - set(log.body["pgaudit"], ParseCSV(Substring(log.body["message"], 7, Len(log.body["message"]) + - 7), "audit_type,statement_id,substatement_id,class,command,object_type,object_name,statement,parameter", + delimiter=",", mode="strict")) + - set(instrumentation_scope.name, "pgaudit") where Len(log.body["pgaudit"]) + > 0 +receivers: + filelog/pgbackrest_log: + include: + - /pgdata/pgbackrest/log/*.log + - /pgdata/pgbackrest/log/*.log.1 + multiline: + line_start_pattern: ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}|^-{19} + storage: file_storage/pgbackrest_logs + filelog/postgres_csvlog: + include: + - /pgdata/logs/postgres/*.csv + multiline: + line_start_pattern: ^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d.\d{3} UTC,(?:"[_\D](?:[^"]|"")*")?,(?:"[_\D](?:[^"]|"")*")?,\d*,(?:"(?:[^"]|"")+")?,[0-9a-f]+[.][0-9a-f]+,\d+, + operators: + - from: body + to: body.original + type: move + - field: body.format + type: add + value: csv + - field: body.headers + type: add + value: timestamp,user,dbname,pid,connection_from,session_id,line_num,ps,session_start,vxid,txid,error_severity,state_code,message,detail,hint,internal_query,internal_position,context,statement,cursor_position,location,application_name,backend_type,leader_pid,query_id + storage: file_storage/postgres_logs + filelog/postgres_jsonlog: + include: + - /pgdata/logs/postgres/*.json + operators: + - from: body + to: body.original + type: move + - field: body.format + type: add + value: json + storage: file_storage/postgres_logs +service: + extensions: + - file_storage/pgbackrest_logs + - file_storage/postgres_logs + pipelines: + logs/pgbackrest: + exporters: + - debug + processors: + - resource/pgbackrest + - transform/pgbackrest_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/pgbackrest_log + logs/postgres: + exporters: + - debug + processors: + - resource/postgres + - transform/postgres_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/postgres_csvlog + - filelog/postgres_jsonlog +`) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.PostgresVersion = 99 + cluster.Spec.Instrumentation = testInstrumentationSpec() + + config := NewConfig(cluster.Spec.Instrumentation) + params := postgres.NewParameterSet() + + EnablePostgresLogging(ctx, cluster, config, params) + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name +extensions: + file_storage/pgbackrest_logs: + create_directory: false + directory: /pgdata/pgbackrest/log/receiver + fsync: true + file_storage/postgres_logs: + create_directory: true + directory: /pgdata/logs/postgres/receiver + fsync: true +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resource/pgbackrest: + attributes: + - action: insert + key: k8s.container.name + value: database + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: pgbackrest + resource/postgres: + attributes: + - action: insert + key: k8s.container.name + value: database + - action: insert + key: k8s.namespace.name + value: ${env:K8S_POD_NAMESPACE} + - action: insert + key: k8s.pod.name + value: ${env:K8S_POD_NAME} + - action: insert + key: process.executable.name + value: postgres + - action: insert + key: db.system + value: postgresql + - action: insert + key: db.version + value: "99" + resourcedetection: + detectors: [] + override: false + timeout: 30s + transform/pgbackrest_logs: + log_statements: + - statements: + - set(instrumentation_scope.name, "pgbackrest") + - set(instrumentation_scope.schema_url, "https://opentelemetry.io/schemas/1.29.0") + - 'merge_maps(log.cache, ExtractPatterns(log.body, "^(?\\d{4}-\\d{2}-\\d{2} + \\d{2}:\\d{2}:\\d{2}\\.\\d{3}) (?P\\d{2,3})\\s*(?\\S*): + (?(?s).*)$"), "insert") where Len(log.body) > 0' + - set(log.severity_text, log.cache["error_severity"]) where IsString(log.cache["error_severity"]) + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == + "TRACE" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG2) where log.severity_text == + "DETAIL" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARN" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.time, Time(log.cache["timestamp"], "%Y-%m-%d %H:%M:%S.%L")) where + IsString(log.cache["timestamp"]) + - set(log.attributes["process.pid"], log.cache["process_id"]) + - set(log.attributes["log.record.original"], log.body) + - set(log.body, log.cache["message"]) + transform/postgres_logs: + log_statements: + - conditions: + - log.body["format"] == "csv" + statements: + - set(log.cache, ParseCSV(log.body["original"], log.body["headers"], delimiter=",", + mode="strict")) + - merge_maps(log.cache, ExtractPatterns(log.cache["connection_from"], "(?:^[[]local[]]:(?.+)|:(?[^:]+))$"), + "insert") where Len(log.cache["connection_from"]) > 0 + - set(log.cache["remote_host"], Substring(log.cache["connection_from"], 0, Len(log.cache["connection_from"]) + - Len(log.cache["remote_port"]) - 1)) where Len(log.cache["connection_from"]) + > 0 and IsString(log.cache["remote_port"]) + - set(log.cache["remote_host"], log.cache["connection_from"]) where Len(log.cache["connection_from"]) + > 0 and not IsString(log.cache["remote_host"]) + - merge_maps(log.cache, ExtractPatterns(log.cache["location"], "^(?:(?[^,]+), + )?(?[^:]+):(?\\d+)$"), "insert") where Len(log.cache["location"]) + > 0 + - set(log.cache["cursor_position"], Double(log.cache["cursor_position"])) where + IsMatch(log.cache["cursor_position"], "^[0-9.]+$") + - set(log.cache["file_line_num"], Double(log.cache["file_line_num"])) where + IsMatch(log.cache["file_line_num"], "^[0-9.]+$") + - set(log.cache["internal_position"], Double(log.cache["internal_position"])) + where IsMatch(log.cache["internal_position"], "^[0-9.]+$") + - set(log.cache["leader_pid"], Double(log.cache["leader_pid"])) where IsMatch(log.cache["leader_pid"], + "^[0-9.]+$") + - set(log.cache["line_num"], Double(log.cache["line_num"])) where IsMatch(log.cache["line_num"], + "^[0-9.]+$") + - set(log.cache["pid"], Double(log.cache["pid"])) where IsMatch(log.cache["pid"], + "^[0-9.]+$") + - set(log.cache["query_id"], Double(log.cache["query_id"])) where IsMatch(log.cache["query_id"], + "^[0-9.]+$") + - set(log.cache["remote_port"], Double(log.cache["remote_port"])) where IsMatch(log.cache["remote_port"], + "^[0-9.]+$") + - set(log.body["parsed"], log.cache) + - statements: + - set(instrumentation_scope.name, "postgres") + - set(instrumentation_scope.version, resource.attributes["db.version"]) + - set(log.cache, log.body["parsed"]) where log.body["format"] == "csv" + - set(log.cache, ParseJSON(log.body["original"])) where log.body["format"] == + "json" + - set(log.severity_text, log.cache["error_severity"]) + - set(log.severity_number, SEVERITY_NUMBER_TRACE) where log.severity_text == + "DEBUG5" + - set(log.severity_number, SEVERITY_NUMBER_TRACE2) where log.severity_text == + "DEBUG4" + - set(log.severity_number, SEVERITY_NUMBER_TRACE3) where log.severity_text == + "DEBUG3" + - set(log.severity_number, SEVERITY_NUMBER_TRACE4) where log.severity_text == + "DEBUG2" + - set(log.severity_number, SEVERITY_NUMBER_DEBUG) where log.severity_text == + "DEBUG1" + - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.severity_text == + "INFO" or log.severity_text == "LOG" + - set(log.severity_number, SEVERITY_NUMBER_INFO2) where log.severity_text == + "NOTICE" + - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.severity_text == + "WARNING" + - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.severity_text == + "ERROR" + - set(log.severity_number, SEVERITY_NUMBER_FATAL) where log.severity_text == + "FATAL" + - set(log.severity_number, SEVERITY_NUMBER_FATAL2) where log.severity_text == + "PANIC" + - set(log.time, Time(log.cache["timestamp"], "%F %T.%L %Z")) where IsString(log.cache["timestamp"]) + - set(instrumentation_scope.schema_url, "https://opentelemetry.io/schemas/1.29.0") + - set(resource.attributes["db.system"], "postgresql") + - set(log.attributes["log.record.original"], log.body["original"]) + - set(log.body, log.cache) + - set(log.attributes["client.address"], log.body["remote_host"]) where IsString(log.body["remote_host"]) + - set(log.attributes["client.port"], Int(log.body["remote_port"])) where IsDouble(log.body["remote_port"]) + - set(log.attributes["code.filepath"], log.body["file_name"]) where IsString(log.body["file_name"]) + - set(log.attributes["code.function"], log.body["func_name"]) where IsString(log.body["func_name"]) + - set(log.attributes["code.lineno"], Int(log.body["file_line_num"])) where IsDouble(log.body["file_line_num"]) + - set(log.attributes["db.namespace"], log.body["dbname"]) where IsString(log.body["dbname"]) + - set(log.attributes["db.response.status_code"], log.body["state_code"]) where + IsString(log.body["state_code"]) + - set(log.attributes["process.creation.time"], Concat([ Substring(log.body["session_start"], + 0, 10), "T", Substring(log.body["session_start"], 11, 8), "Z"], "")) where + IsMatch(log.body["session_start"], "^[^ ]{10} [^ ]{8} UTC$") + - set(log.attributes["process.pid"], Int(log.body["pid"])) where IsDouble(log.body["pid"]) + - set(log.attributes["process.title"], log.body["ps"]) where IsString(log.body["ps"]) + - set(log.attributes["user.name"], log.body["user"]) where IsString(log.body["user"]) + - conditions: + - 'Len(log.body["message"]) > 7 and Substring(log.body["message"], 0, 7) == + "AUDIT: "' + statements: + - set(log.body["pgaudit"], ParseCSV(Substring(log.body["message"], 7, Len(log.body["message"]) + - 7), "audit_type,statement_id,substatement_id,class,command,object_type,object_name,statement,parameter", + delimiter=",", mode="strict")) + - set(instrumentation_scope.name, "pgaudit") where Len(log.body["pgaudit"]) + > 0 +receivers: + filelog/pgbackrest_log: + include: + - /pgdata/pgbackrest/log/*.log + - /pgdata/pgbackrest/log/*.log.1 + multiline: + line_start_pattern: ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}|^-{19} + storage: file_storage/pgbackrest_logs + filelog/postgres_csvlog: + include: + - /pgdata/logs/postgres/*.csv + multiline: + line_start_pattern: ^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d.\d{3} UTC,(?:"[_\D](?:[^"]|"")*")?,(?:"[_\D](?:[^"]|"")*")?,\d*,(?:"(?:[^"]|"")+")?,[0-9a-f]+[.][0-9a-f]+,\d+, + operators: + - from: body + to: body.original + type: move + - field: body.format + type: add + value: csv + - field: body.headers + type: add + value: timestamp,user,dbname,pid,connection_from,session_id,line_num,ps,session_start,vxid,txid,error_severity,state_code,message,detail,hint,internal_query,internal_position,context,statement,cursor_position,location,application_name,backend_type,leader_pid,query_id + storage: file_storage/postgres_logs + filelog/postgres_jsonlog: + include: + - /pgdata/logs/postgres/*.json + operators: + - from: body + to: body.original + type: move + - field: body.format + type: add + value: json + storage: file_storage/postgres_logs +service: + extensions: + - file_storage/pgbackrest_logs + - file_storage/postgres_logs + pipelines: + logs/pgbackrest: + exporters: + - googlecloud + processors: + - resource/pgbackrest + - transform/pgbackrest_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/pgbackrest_log + logs/postgres: + exporters: + - googlecloud + processors: + - resource/postgres + - transform/postgres_logs + - resourcedetection + - batch/logs + - groupbyattrs/compact + receivers: + - filelog/postgres_csvlog + - filelog/postgres_jsonlog +`) + }) +} + +func TestEnablePostgresMetrics(t *testing.T) { + t.Run("EmptyInstrumentationSpec", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.PostgresVersion = 99 + require.UnmarshalInto(t, &cluster.Spec, `{ + instrumentation: {} + }`) + + config := NewConfig(nil) + + EnablePostgresMetrics(ctx, cluster, config) + + // The queries aren't really needed for this test and sheer number of queries + // would make this file excessively long (and string formatting presented it's + // own formatting headaches), so I am removing them + config.Receivers["sqlquery/5s"] = nil + config.Receivers["sqlquery/300s"] = nil + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + prometheus/cpk-monitoring: + endpoint: 0.0.0.0:9187 +extensions: {} +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resourcedetection: + detectors: [] + override: false + timeout: 30s +receivers: + sqlquery/5s: null + sqlquery/300s: null +service: + extensions: [] + pipelines: + metrics/postgres: + exporters: + - prometheus/cpk-monitoring + processors: + - batch/200ms + - groupbyattrs/compact + receivers: + - sqlquery/5s + - sqlquery/300s +`) + }) + + t.Run("InstrumentationSpecDefined", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + cluster := new(v1beta1.PostgresCluster) + cluster.Spec.PostgresVersion = 99 + cluster.Spec.Instrumentation = testInstrumentationSpec() + + config := NewConfig(cluster.Spec.Instrumentation) + + EnablePostgresMetrics(ctx, cluster, config) + + // The queries aren't really needed for this test and sheer number of queries + // would make this file excessively long (and string formatting presented it's + // own formatting headaches), so I am removing them + config.Receivers["sqlquery/5s"] = nil + config.Receivers["sqlquery/300s"] = nil + + result, err := config.ToYAML() + assert.NilError(t, err) + assert.DeepEqual(t, result, `# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +exporters: + debug: + verbosity: detailed + googlecloud: + log: + default_log_name: opentelemetry.io/collector-exported-log + project: google-project-name + prometheus/cpk-monitoring: + endpoint: 0.0.0.0:9187 +extensions: {} +processors: + batch/1s: + timeout: 1s + batch/200ms: + timeout: 200ms + batch/logs: + send_batch_size: 8192 + timeout: 200ms + groupbyattrs/compact: {} + resourcedetection: + detectors: [] + override: false + timeout: 30s +receivers: + sqlquery/5s: null + sqlquery/300s: null +service: + extensions: [] + pipelines: + metrics/postgres: + exporters: + - prometheus/cpk-monitoring + - googlecloud + processors: + - batch/200ms + - groupbyattrs/compact + receivers: + - sqlquery/5s + - sqlquery/300s +`) + + }) +} diff --git a/internal/collector/util.go b/internal/collector/util.go new file mode 100644 index 0000000000..72cf8641ef --- /dev/null +++ b/internal/collector/util.go @@ -0,0 +1,56 @@ +// Copyright 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package collector + +import ( + "context" + + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +type CrunchyCRD interface { + *v1beta1.PostgresCluster | *v1beta1.PGAdmin | *v1beta1.InstrumentationSpec +} + +func OpenTelemetrySpecPresent[T CrunchyCRD](object T) bool { + + switch v := any(object).(type) { + case *v1beta1.InstrumentationSpec: + return v != nil + case *v1beta1.PostgresCluster: + return v.Spec.Instrumentation != nil + case *v1beta1.PGAdmin: + return v.Spec.Instrumentation != nil + default: + return false + } + +} + +func OpenTelemetryLogsOrMetricsEnabled[T CrunchyCRD]( + ctx context.Context, + object T, +) bool { + return OpenTelemetrySpecPresent(object) && + (feature.Enabled(ctx, feature.OpenTelemetryLogs) || + feature.Enabled(ctx, feature.OpenTelemetryMetrics)) +} + +func OpenTelemetryLogsEnabled[T CrunchyCRD]( + ctx context.Context, + object T, +) bool { + return OpenTelemetrySpecPresent(object) && + feature.Enabled(ctx, feature.OpenTelemetryLogs) +} + +func OpenTelemetryMetricsEnabled[T CrunchyCRD]( + ctx context.Context, + object T, +) bool { + return OpenTelemetrySpecPresent(object) && + feature.Enabled(ctx, feature.OpenTelemetryMetrics) +} diff --git a/internal/config/config.go b/internal/config/config.go index e3f9ced215..ed8d87c5d0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -22,20 +22,26 @@ func defaultFromEnv(value, key string) string { // FetchKeyCommand returns the fetch_key_cmd value stored in the encryption_key_command // variable used to enable TDE. func FetchKeyCommand(spec *v1beta1.PostgresClusterSpec) string { + if config := spec.Config; config != nil { + if parameters := config.Parameters; parameters != nil { + if v, ok := parameters["encryption_key_command"]; ok { + return v.String() + } + } + } + if spec.Patroni != nil { - if spec.Patroni.DynamicConfiguration != nil { - configuration := spec.Patroni.DynamicConfiguration - if configuration != nil { - if postgresql, ok := configuration["postgresql"].(map[string]any); ok { - if parameters, ok := postgresql["parameters"].(map[string]any); ok { - if parameters["encryption_key_command"] != nil { - return fmt.Sprintf("%s", parameters["encryption_key_command"]) - } + if configuration := spec.Patroni.DynamicConfiguration; configuration != nil { + if postgresql, ok := configuration["postgresql"].(map[string]any); ok { + if parameters, ok := postgresql["parameters"].(map[string]any); ok { + if parameters["encryption_key_command"] != nil { + return fmt.Sprintf("%s", parameters["encryption_key_command"]) } } } } } + return "" } @@ -99,6 +105,17 @@ func PGExporterContainerImage(cluster *v1beta1.PostgresCluster) string { return defaultFromEnv(image, "RELATED_IMAGE_PGEXPORTER") } +// CollectorContainerImage returns the container image to use for the +// collector container. +func CollectorContainerImage(instrumentation *v1beta1.InstrumentationSpec) string { + var image string + if instrumentation != nil { + image = instrumentation.Image + } + + return defaultFromEnv(image, "RELATED_IMAGE_COLLECTOR") +} + // PostgresContainerImage returns the container image to use for PostgreSQL. func PostgresContainerImage(cluster *v1beta1.PostgresCluster) string { image := cluster.Spec.Image @@ -143,6 +160,10 @@ func VerifyImageValues(cluster *v1beta1.PostgresCluster) error { cluster.Spec.Monitoring.PGMonitor.Exporter != nil { images = append(images, "crunchy-postgres-exporter") } + if CollectorContainerImage(cluster.Spec.Instrumentation) == "" && + cluster.Spec.Instrumentation != nil { + images = append(images, "crunchy-collector") + } if PostgresContainerImage(cluster) == "" { if cluster.Spec.PostGISVersion != "" { images = append(images, "crunchy-postgres-gis") @@ -152,7 +173,7 @@ func VerifyImageValues(cluster *v1beta1.PostgresCluster) error { } if len(images) > 0 { - return fmt.Errorf("Missing image(s): %s", images) + return fmt.Errorf("missing image(s): %s", images) } return nil diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7b8ca2f863..a6e40adddd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -9,74 +9,121 @@ import ( "testing" "gotest.tools/v3/assert" - "sigs.k8s.io/yaml" + "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) func TestFetchKeyCommand(t *testing.T) { - - spec1 := v1beta1.PostgresClusterSpec{} - assert.Assert(t, FetchKeyCommand(&spec1) == "") - - spec2 := v1beta1.PostgresClusterSpec{ - Patroni: &v1beta1.PatroniSpec{}, - } - assert.Assert(t, FetchKeyCommand(&spec2) == "") - - spec3 := v1beta1.PostgresClusterSpec{ - Patroni: &v1beta1.PatroniSpec{ - DynamicConfiguration: map[string]any{}, - }, - } - assert.Assert(t, FetchKeyCommand(&spec3) == "") - - spec4 := v1beta1.PostgresClusterSpec{ - Patroni: &v1beta1.PatroniSpec{ - DynamicConfiguration: map[string]any{ - "postgresql": map[string]any{}, + t.Run("missing", func(t *testing.T) { + spec1 := v1beta1.PostgresClusterSpec{} + assert.Assert(t, FetchKeyCommand(&spec1) == "") + + spec2 := v1beta1.PostgresClusterSpec{ + Patroni: &v1beta1.PatroniSpec{}, + } + assert.Assert(t, FetchKeyCommand(&spec2) == "") + + spec3 := v1beta1.PostgresClusterSpec{ + Patroni: &v1beta1.PatroniSpec{ + DynamicConfiguration: map[string]any{}, }, - }, - } - assert.Assert(t, FetchKeyCommand(&spec4) == "") + } + assert.Assert(t, FetchKeyCommand(&spec3) == "") - spec5 := v1beta1.PostgresClusterSpec{ - Patroni: &v1beta1.PatroniSpec{ - DynamicConfiguration: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{}, + spec4 := v1beta1.PostgresClusterSpec{ + Patroni: &v1beta1.PatroniSpec{ + DynamicConfiguration: map[string]any{ + "postgresql": map[string]any{}, }, }, - }, - } - assert.Assert(t, FetchKeyCommand(&spec5) == "") - - spec6 := v1beta1.PostgresClusterSpec{ - Patroni: &v1beta1.PatroniSpec{ - DynamicConfiguration: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "encryption_key_command": "", + } + assert.Assert(t, FetchKeyCommand(&spec4) == "") + + spec5 := v1beta1.PostgresClusterSpec{ + Patroni: &v1beta1.PatroniSpec{ + DynamicConfiguration: map[string]any{ + "postgresql": map[string]any{ + "parameters": map[string]any{}, }, }, }, - }, - } - assert.Assert(t, FetchKeyCommand(&spec6) == "") - - spec7 := v1beta1.PostgresClusterSpec{ - Patroni: &v1beta1.PatroniSpec{ - DynamicConfiguration: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "encryption_key_command": "echo mykey", + } + assert.Assert(t, FetchKeyCommand(&spec5) == "") + }) + + t.Run("blank", func(t *testing.T) { + var spec1 v1beta1.PostgresClusterSpec + require.UnmarshalInto(t, &spec1, `{ + patroni: { + dynamicConfiguration: { + postgresql: { + parameters: { + encryption_key_command: "", + }, }, }, }, - }, - } - assert.Assert(t, FetchKeyCommand(&spec7) == "echo mykey") + }`) + assert.Equal(t, "", FetchKeyCommand(&spec1)) + + var spec2 v1beta1.PostgresClusterSpec + require.UnmarshalInto(t, &spec2, `{ + config: { + parameters: { + encryption_key_command: "", + }, + }, + }`) + assert.Equal(t, "", FetchKeyCommand(&spec2)) + }) + + t.Run("exists", func(t *testing.T) { + var spec1 v1beta1.PostgresClusterSpec + require.UnmarshalInto(t, &spec1, `{ + patroni: { + dynamicConfiguration: { + postgresql: { + parameters: { + encryption_key_command: "echo mykey", + }, + }, + }, + }, + }`) + assert.Equal(t, "echo mykey", FetchKeyCommand(&spec1)) + + var spec2 v1beta1.PostgresClusterSpec + require.UnmarshalInto(t, &spec2, `{ + config: { + parameters: { + encryption_key_command: "cat somefile", + }, + }, + }`) + assert.Equal(t, "cat somefile", FetchKeyCommand(&spec2)) + }) + t.Run("config.parameters takes precedence", func(t *testing.T) { + var spec v1beta1.PostgresClusterSpec + require.UnmarshalInto(t, &spec, `{ + config: { + parameters: { + encryption_key_command: "cat somefile", + }, + }, + patroni: { + dynamicConfiguration: { + postgresql: { + parameters: { + encryption_key_command: "echo mykey", + }, + }, + }, + }, + }`) + assert.Equal(t, "cat somefile", FetchKeyCommand(&spec)) + }) } func TestPGAdminContainerImage(t *testing.T) { @@ -92,9 +139,9 @@ func TestPGAdminContainerImage(t *testing.T) { t.Setenv("RELATED_IMAGE_PGADMIN", "env-var-pgadmin") assert.Equal(t, PGAdminContainerImage(cluster), "env-var-pgadmin") - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, &cluster.Spec, `{ userInterface: { pgAdmin: { image: spec-image } }, - }`), &cluster.Spec)) + }`) assert.Equal(t, PGAdminContainerImage(cluster), "spec-image") } @@ -111,9 +158,9 @@ func TestPGBackRestContainerImage(t *testing.T) { t.Setenv("RELATED_IMAGE_PGBACKREST", "env-var-pgbackrest") assert.Equal(t, PGBackRestContainerImage(cluster), "env-var-pgbackrest") - assert.NilError(t, yaml.Unmarshal([]byte(`{ - backups: { pgBackRest: { image: spec-image } }, - }`), &cluster.Spec)) + require.UnmarshalInto(t, &cluster.Spec, `{ + backups: { pgbackrest: { image: spec-image } }, + }`) assert.Equal(t, PGBackRestContainerImage(cluster), "spec-image") } @@ -130,9 +177,9 @@ func TestPGBouncerContainerImage(t *testing.T) { t.Setenv("RELATED_IMAGE_PGBOUNCER", "env-var-pgbouncer") assert.Equal(t, PGBouncerContainerImage(cluster), "env-var-pgbouncer") - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, &cluster.Spec, `{ proxy: { pgBouncer: { image: spec-image } }, - }`), &cluster.Spec)) + }`) assert.Equal(t, PGBouncerContainerImage(cluster), "spec-image") } @@ -149,9 +196,9 @@ func TestPGExporterContainerImage(t *testing.T) { t.Setenv("RELATED_IMAGE_PGEXPORTER", "env-var-pgexporter") assert.Equal(t, PGExporterContainerImage(cluster), "env-var-pgexporter") - assert.NilError(t, yaml.Unmarshal([]byte(`{ - monitoring: { pgMonitor: { exporter: { image: spec-image } } }, - }`), &cluster.Spec)) + require.UnmarshalInto(t, &cluster.Spec, `{ + monitoring: { pgmonitor: { exporter: { image: spec-image } } }, + }`) assert.Equal(t, PGExporterContainerImage(cluster), "spec-image") } @@ -168,9 +215,9 @@ func TestStandalonePGAdminContainerImage(t *testing.T) { t.Setenv("RELATED_IMAGE_STANDALONE_PGADMIN", "env-var-pgadmin") assert.Equal(t, StandalonePGAdminContainerImage(pgadmin), "env-var-pgadmin") - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, &pgadmin.Spec, `{ image: spec-image - }`), &pgadmin.Spec)) + }`) assert.Equal(t, StandalonePGAdminContainerImage(pgadmin), "spec-image") } diff --git a/internal/controller/pgupgrade/apply.go b/internal/controller/pgupgrade/apply.go index 71cf65cd4f..fb0c55950e 100644 --- a/internal/controller/pgupgrade/apply.go +++ b/internal/controller/pgupgrade/apply.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/pgupgrade/jobs.go b/internal/controller/pgupgrade/jobs.go index a1722dfc12..4715c8da93 100644 --- a/internal/controller/pgupgrade/jobs.go +++ b/internal/controller/pgupgrade/jobs.go @@ -1,21 +1,26 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package pgupgrade import ( + "cmp" "context" "fmt" + "math" "strings" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -32,9 +37,16 @@ func pgUpgradeJob(upgrade *v1beta1.PGUpgrade) metav1.ObjectMeta { // upgradeCommand returns an entrypoint that prepares the filesystem for // and performs a PostgreSQL major version upgrade using pg_upgrade. -func upgradeCommand(upgrade *v1beta1.PGUpgrade, fetchKeyCommand string) []string { - oldVersion := fmt.Sprint(upgrade.Spec.FromPostgresVersion) - newVersion := fmt.Sprint(upgrade.Spec.ToPostgresVersion) +func upgradeCommand(spec *v1beta1.PGUpgradeSettings, fetchKeyCommand string) []string { + argJobs := fmt.Sprintf(` --jobs=%d`, max(1, spec.Jobs)) + argMethod := cmp.Or(map[string]string{ + "Clone": ` --clone`, + "Copy": ` --copy`, + "CopyFileRange": ` --copy-file-range`, + }[spec.TransferMethod], ` --link`) + + oldVersion := spec.FromPostgresVersion + newVersion := spec.ToPostgresVersion // if the fetch key command is set for TDE, provide the value during initialization initdb := `/usr/pgsql-"${new_version}"/bin/initdb -k -D /pgdata/pg"${new_version}"` @@ -42,7 +54,7 @@ func upgradeCommand(upgrade *v1beta1.PGUpgrade, fetchKeyCommand string) []string initdb += ` --encryption-key-command "` + fetchKeyCommand + `"` } - args := []string{oldVersion, newVersion} + args := []string{fmt.Sprint(oldVersion), fmt.Sprint(newVersion)} script := strings.Join([]string{ `declare -r data_volume='/pgdata' old_version="$1" new_version="$2"`, `printf 'Performing PostgreSQL upgrade from version "%s" to "%s" ...\n\n' "$@"`, @@ -86,7 +98,7 @@ func upgradeCommand(upgrade *v1beta1.PGUpgrade, fetchKeyCommand string) []string // proper permissions have to be set on the old pgdata directory and the // preload library settings must be copied over. `echo -e "\nStep 3: Setting the expected permissions on the old pgdata directory...\n"`, - `chmod 700 /pgdata/pg"${old_version}"`, + `chmod 750 /pgdata/pg"${old_version}"`, `echo -e "Step 4: Copying shared_preload_libraries setting to new postgresql.conf file...\n"`, `echo "shared_preload_libraries = '$(/usr/pgsql-"""${old_version}"""/bin/postgres -D \`, `/pgdata/pg"""${old_version}""" -C shared_preload_libraries)'" >> /pgdata/pg"${new_version}"/postgresql.conf`, @@ -96,14 +108,14 @@ func upgradeCommand(upgrade *v1beta1.PGUpgrade, fetchKeyCommand string) []string `echo -e "Step 5: Running pg_upgrade check...\n"`, `time /usr/pgsql-"${new_version}"/bin/pg_upgrade --old-bindir /usr/pgsql-"${old_version}"/bin \`, `--new-bindir /usr/pgsql-"${new_version}"/bin --old-datadir /pgdata/pg"${old_version}"\`, - ` --new-datadir /pgdata/pg"${new_version}" --link --check`, + ` --new-datadir /pgdata/pg"${new_version}" --check` + argMethod + argJobs, // Assuming the check completes successfully, the pg_upgrade command will // be run that actually prepares the upgraded pgdata directory. `echo -e "\nStep 6: Running pg_upgrade...\n"`, `time /usr/pgsql-"${new_version}"/bin/pg_upgrade --old-bindir /usr/pgsql-"${old_version}"/bin \`, `--new-bindir /usr/pgsql-"${new_version}"/bin --old-datadir /pgdata/pg"${old_version}" \`, - `--new-datadir /pgdata/pg"${new_version}" --link`, + `--new-datadir /pgdata/pg"${new_version}"` + argMethod + argJobs, // Since we have cleared the Patroni cluster step by removing the EndPoints, we copy patroni.dynamic.json // from the old data dir to help retain PostgreSQL parameters you had set before. @@ -117,10 +129,21 @@ func upgradeCommand(upgrade *v1beta1.PGUpgrade, fetchKeyCommand string) []string return append([]string{"bash", "-ceu", "--", script, "upgrade"}, args...) } +// largestWholeCPU returns the maximum CPU request or limit as a non-negative +// integer of CPUs. When resources lacks any CPU, the result is zero. +func largestWholeCPU(resources corev1.ResourceRequirements) int64 { + // Read CPU quantities as millicores then divide to get the "floor." + // NOTE: [resource.Quantity.Value] looks easier, but it rounds up. + return max( + resources.Limits.Cpu().ScaledValue(resource.Milli)/1000, + resources.Requests.Cpu().ScaledValue(resource.Milli)/1000, + 0) +} + // generateUpgradeJob returns a Job that can upgrade the PostgreSQL data // directory of the startup instance. func (r *PGUpgradeReconciler) generateUpgradeJob( - _ context.Context, upgrade *v1beta1.PGUpgrade, + ctx context.Context, upgrade *v1beta1.PGUpgrade, startup *appsv1.StatefulSet, fetchKeyCommand string, ) *batchv1.Job { job := &batchv1.Job{} @@ -129,7 +152,6 @@ func (r *PGUpgradeReconciler) generateUpgradeJob( job.Namespace = upgrade.Namespace job.Name = pgUpgradeJob(upgrade).Name - job.Annotations = upgrade.Spec.Metadata.GetAnnotationsOrNil() job.Labels = Merge(upgrade.Spec.Metadata.GetLabelsOrNil(), commonLabels(pgUpgrade, upgrade), //FIXME role pgupgrade map[string]string{ @@ -145,6 +167,11 @@ func (r *PGUpgradeReconciler) generateUpgradeJob( } } + job.Annotations = Merge(upgrade.Spec.Metadata.GetAnnotationsOrNil(), + map[string]string{ + naming.DefaultContainerAnnotation: database.Name, + }) + // Copy the pod template from the startup instance StatefulSet. This includes // the service account, volumes, DNS policies, and scheduling constraints. startup.Spec.Template.DeepCopyInto(&job.Spec.Template) @@ -162,6 +189,15 @@ func (r *PGUpgradeReconciler) generateUpgradeJob( job.Spec.BackoffLimit = initialize.Int32(0) job.Spec.Template.Spec.RestartPolicy = corev1.RestartPolicyNever + settings := upgrade.Spec.PGUpgradeSettings.DeepCopy() + + // When jobs is undefined, use one less than the number of CPUs. + //nolint:gosec // The CPU count is clamped to MaxInt32. + if settings.Jobs == 0 && feature.Enabled(ctx, feature.PGUpgradeCPUConcurrency) { + wholeCPUs := int32(min(math.MaxInt32, largestWholeCPU(upgrade.Spec.Resources))) + settings.Jobs = wholeCPUs - 1 + } + // Replace all containers with one that does the upgrade. job.Spec.Template.Spec.EphemeralContainers = nil job.Spec.Template.Spec.InitContainers = nil @@ -174,7 +210,7 @@ func (r *PGUpgradeReconciler) generateUpgradeJob( VolumeMounts: database.VolumeMounts, // Use our upgrade command and the specified image and resources. - Command: upgradeCommand(upgrade, fetchKeyCommand), + Command: upgradeCommand(settings, fetchKeyCommand), Image: pgUpgradeContainerImage(upgrade), ImagePullPolicy: upgrade.Spec.ImagePullPolicy, Resources: upgrade.Spec.Resources, @@ -241,7 +277,6 @@ func (r *PGUpgradeReconciler) generateRemoveDataJob( job.Namespace = upgrade.Namespace job.Name = upgrade.Name + "-" + sts.Name - job.Annotations = upgrade.Spec.Metadata.GetAnnotationsOrNil() job.Labels = labels.Merge(upgrade.Spec.Metadata.GetLabelsOrNil(), commonLabels(removeData, upgrade)) //FIXME role removedata @@ -254,6 +289,11 @@ func (r *PGUpgradeReconciler) generateRemoveDataJob( } } + job.Annotations = Merge(upgrade.Spec.Metadata.GetAnnotationsOrNil(), + map[string]string{ + naming.DefaultContainerAnnotation: database.Name, + }) + // Copy the pod template from the sts instance StatefulSet. This includes // the service account, volumes, DNS policies, and scheduling constraints. sts.Spec.Template.DeepCopyInto(&job.Spec.Template) @@ -315,7 +355,7 @@ func pgUpgradeContainerImage(upgrade *v1beta1.PGUpgrade) string { // spec is defined. If it is undefined, an error is returned. func verifyUpgradeImageValue(upgrade *v1beta1.PGUpgrade) error { if pgUpgradeContainerImage(upgrade) == "" { - return fmt.Errorf("Missing crunchy-upgrade image") + return fmt.Errorf("missing crunchy-upgrade image") } return nil } diff --git a/internal/controller/pgupgrade/jobs_test.go b/internal/controller/pgupgrade/jobs_test.go index 8dfc4731a2..a94641d4c6 100644 --- a/internal/controller/pgupgrade/jobs_test.go +++ b/internal/controller/pgupgrade/jobs_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -16,11 +16,112 @@ import ( "k8s.io/apimachinery/pkg/api/resource" "sigs.k8s.io/yaml" + "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) +func TestLargestWholeCPU(t *testing.T) { + assert.Equal(t, int64(0), + largestWholeCPU(corev1.ResourceRequirements{}), + "expected the zero value to be zero") + + for _, tt := range []struct { + Name, ResourcesYAML string + Result int64 + }{ + { + Name: "Negatives", ResourcesYAML: `{requests: {cpu: -3}, limits: {cpu: -5}}`, + Result: 0, + }, + { + Name: "SmallPositive", ResourcesYAML: `limits: {cpu: 600m}`, + Result: 0, + }, + { + Name: "FractionalPositive", ResourcesYAML: `requests: {cpu: 2200m}`, + Result: 2, + }, + { + Name: "LargePositive", ResourcesYAML: `limits: {cpu: 10}`, + Result: 10, + }, + { + Name: "RequestsAndLimits", ResourcesYAML: `{requests: {cpu: 2}, limits: {cpu: 4}}`, + Result: 4, + }, + } { + t.Run(tt.Name, func(t *testing.T) { + var resources corev1.ResourceRequirements + require.UnmarshalInto(t, &resources, tt.ResourcesYAML) + assert.Equal(t, tt.Result, largestWholeCPU(resources)) + }) + } +} + +func TestUpgradeCommand(t *testing.T) { + expectScript := func(t *testing.T, script string) { + t.Helper() + + t.Run("PrettyYAML", func(t *testing.T) { + b, err := yaml.Marshal(script) + assert.NilError(t, err) + assert.Assert(t, strings.HasPrefix(string(b), `|`), + "expected literal block scalar, got:\n%s", b) + }) + } + + t.Run("Jobs", func(t *testing.T) { + for _, tt := range []struct { + Spec int32 + Args string + }{ + {Spec: -1, Args: "--jobs=1"}, + {Spec: 0, Args: "--jobs=1"}, + {Spec: 1, Args: "--jobs=1"}, + {Spec: 2, Args: "--jobs=2"}, + {Spec: 10, Args: "--jobs=10"}, + } { + spec := &v1beta1.PGUpgradeSettings{Jobs: tt.Spec} + command := upgradeCommand(spec, "") + assert.Assert(t, len(command) > 3) + assert.DeepEqual(t, []string{"bash", "-ceu", "--"}, command[:3]) + + script := command[3] + assert.Assert(t, cmp.Contains(script, tt.Args)) + + expectScript(t, script) + } + }) + + t.Run("Method", func(t *testing.T) { + for _, tt := range []struct { + Spec string + Args string + }{ + {Spec: "", Args: "--link"}, + {Spec: "mystery!", Args: "--link"}, + {Spec: "Link", Args: "--link"}, + {Spec: "Clone", Args: "--clone"}, + {Spec: "Copy", Args: "--copy"}, + {Spec: "CopyFileRange", Args: "--copy-file-range"}, + } { + spec := &v1beta1.PGUpgradeSettings{TransferMethod: tt.Spec} + command := upgradeCommand(spec, "") + assert.Assert(t, len(command) > 3) + assert.DeepEqual(t, []string{"bash", "-ceu", "--"}, command[:3]) + + script := command[3] + assert.Assert(t, cmp.Contains(script, tt.Args)) + + expectScript(t, script) + } + + }) +} + func TestGenerateUpgradeJob(t *testing.T) { ctx := context.Background() reconciler := &PGUpgradeReconciler{} @@ -62,6 +163,8 @@ func TestGenerateUpgradeJob(t *testing.T) { apiVersion: batch/v1 kind: Job metadata: + annotations: + kubectl.kubernetes.io/default-container: database creationTimestamp: null labels: postgres-operator.crunchydata.com/cluster: pg5 @@ -81,6 +184,8 @@ spec: backoffLimit: 0 template: metadata: + annotations: + kubectl.kubernetes.io/default-container: database creationTimestamp: null labels: postgres-operator.crunchydata.com/cluster: pg5 @@ -109,18 +214,18 @@ spec: echo -e "Step 2: Initializing new pgdata directory...\n" /usr/pgsql-"${new_version}"/bin/initdb -k -D /pgdata/pg"${new_version}" echo -e "\nStep 3: Setting the expected permissions on the old pgdata directory...\n" - chmod 700 /pgdata/pg"${old_version}" + chmod 750 /pgdata/pg"${old_version}" echo -e "Step 4: Copying shared_preload_libraries setting to new postgresql.conf file...\n" echo "shared_preload_libraries = '$(/usr/pgsql-"""${old_version}"""/bin/postgres -D \ /pgdata/pg"""${old_version}""" -C shared_preload_libraries)'" >> /pgdata/pg"${new_version}"/postgresql.conf echo -e "Step 5: Running pg_upgrade check...\n" time /usr/pgsql-"${new_version}"/bin/pg_upgrade --old-bindir /usr/pgsql-"${old_version}"/bin \ --new-bindir /usr/pgsql-"${new_version}"/bin --old-datadir /pgdata/pg"${old_version}"\ - --new-datadir /pgdata/pg"${new_version}" --link --check + --new-datadir /pgdata/pg"${new_version}" --check --link --jobs=1 echo -e "\nStep 6: Running pg_upgrade...\n" time /usr/pgsql-"${new_version}"/bin/pg_upgrade --old-bindir /usr/pgsql-"${old_version}"/bin \ --new-bindir /usr/pgsql-"${new_version}"/bin --old-datadir /pgdata/pg"${old_version}" \ - --new-datadir /pgdata/pg"${new_version}" --link + --new-datadir /pgdata/pg"${new_version}" --link --jobs=1 echo -e "\nStep 7: Copying patroni.dynamic.json...\n" cp /pgdata/pg"${old_version}"/patroni.dynamic.json /pgdata/pg"${new_version}" echo -e "\npg_upgrade Job Complete!" @@ -145,9 +250,19 @@ spec: status: {} `)) + t.Run(feature.PGUpgradeCPUConcurrency+"Enabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.PGUpgradeCPUConcurrency: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + job := reconciler.generateUpgradeJob(ctx, upgrade, startup, "") + assert.Assert(t, cmp.MarshalContains(job, `--jobs=2`)) + }) + tdeJob := reconciler.generateUpgradeJob(ctx, upgrade, startup, "echo testKey") - b, _ := yaml.Marshal(tdeJob) - assert.Assert(t, strings.Contains(string(b), + assert.Assert(t, cmp.MarshalContains(tdeJob, `/usr/pgsql-"${new_version}"/bin/initdb -k -D /pgdata/pg"${new_version}" --encryption-key-command "echo testKey"`)) } @@ -193,6 +308,8 @@ func TestGenerateRemoveDataJob(t *testing.T) { apiVersion: batch/v1 kind: Job metadata: + annotations: + kubectl.kubernetes.io/default-container: database creationTimestamp: null labels: postgres-operator.crunchydata.com/cluster: pg5 @@ -211,6 +328,8 @@ spec: backoffLimit: 0 template: metadata: + annotations: + kubectl.kubernetes.io/default-container: database creationTimestamp: null labels: postgres-operator.crunchydata.com/cluster: pg5 @@ -265,8 +384,7 @@ func TestPGUpgradeContainerImage(t *testing.T) { t.Setenv("RELATED_IMAGE_PGUPGRADE", "env-var-pgbackrest") assert.Equal(t, pgUpgradeContainerImage(upgrade), "env-var-pgbackrest") - assert.NilError(t, yaml.Unmarshal( - []byte(`{ image: spec-image }`), &upgrade.Spec)) + require.UnmarshalInto(t, &upgrade.Spec, `{ image: spec-image }`) assert.Equal(t, pgUpgradeContainerImage(upgrade), "spec-image") } diff --git a/internal/controller/pgupgrade/labels.go b/internal/controller/pgupgrade/labels.go index 187fe6bf6f..ac433e2a0b 100644 --- a/internal/controller/pgupgrade/labels.go +++ b/internal/controller/pgupgrade/labels.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/pgupgrade/pgupgrade_controller.go b/internal/controller/pgupgrade/pgupgrade_controller.go index d6d145b793..06a36574f0 100644 --- a/internal/controller/pgupgrade/pgupgrade_controller.go +++ b/internal/controller/pgupgrade/pgupgrade_controller.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -14,15 +14,15 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/record" - "k8s.io/client-go/util/workqueue" 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/handler" "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/controller/runtime" + "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/registration" + "github.com/crunchydata/postgres-operator/internal/tracing" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -50,7 +50,9 @@ func (r *PGUpgradeReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&batchv1.Job{}). Watches( v1beta1.NewPostgresCluster(), - r.watchPostgresClusters(), + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, cluster client.Object) []ctrl.Request { + return runtime.Requests(r.findUpgradesForPostgresCluster(ctx, client.ObjectKeyFromObject(cluster))...) + }), ). Complete(r) } @@ -80,31 +82,6 @@ func (r *PGUpgradeReconciler) findUpgradesForPostgresCluster( return matching } -// watchPostgresClusters returns a [handler.EventHandler] for PostgresClusters. -func (r *PGUpgradeReconciler) watchPostgresClusters() handler.Funcs { - handle := func(ctx context.Context, cluster client.Object, q workqueue.RateLimitingInterface) { - key := client.ObjectKeyFromObject(cluster) - - for _, upgrade := range r.findUpgradesForPostgresCluster(ctx, key) { - q.Add(ctrl.Request{ - NamespacedName: client.ObjectKeyFromObject(upgrade), - }) - } - } - - return handler.Funcs{ - CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.Object, q) - }, - UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.ObjectNew, q) - }, - DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.Object, q) - }, - } -} - //+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="pgupgrades",verbs={get} //+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="pgupgrades/status",verbs={patch} //+kubebuilder:rbac:groups="batch",resources="jobs",verbs={delete} @@ -118,7 +95,10 @@ func (r *PGUpgradeReconciler) watchPostgresClusters() handler.Funcs { // Reconcile does the work to move the current state of the world toward the // desired state described in a [v1beta1.PGUpgrade] identified by req. func (r *PGUpgradeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { - log := ctrl.LoggerFrom(ctx) + ctx, span := tracing.Start(ctx, "reconcile-pgupgrade") + log := logging.FromContext(ctx) + defer span.End() + defer func(s tracing.Span) { _ = tracing.Escape(s, err) }(span) // Retrieve the upgrade from the client cache, if it exists. A deferred // function below will send any changes to its Status field. @@ -173,6 +153,7 @@ func (r *PGUpgradeReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( setStatusToProgressingIfReasonWas("", upgrade) // The "from" version must be smaller than the "to" version. + // NOTE: CRD validation also rejects these values. // An invalid PGUpgrade should not be requeued. if upgrade.Spec.FromPostgresVersion >= upgrade.Spec.ToPostgresVersion { @@ -438,7 +419,7 @@ func (r *PGUpgradeReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Set the cluster status when we know the upgrade has completed successfully. // This will serve to help the user see that the upgrade has completed if they // are only watching the PostgresCluster - patch.Status.PostgresVersion = upgrade.Spec.ToPostgresVersion + patch.Status.PostgresVersion = int(upgrade.Spec.ToPostgresVersion) // Set the pgBackRest status for bootstrapping patch.Status.PGBackRest.Repos = []v1beta1.RepoStatus{} diff --git a/internal/controller/pgupgrade/registration.go b/internal/controller/pgupgrade/registration.go index 05d0d80cbd..4fbf7a7ce1 100644 --- a/internal/controller/pgupgrade/registration.go +++ b/internal/controller/pgupgrade/registration.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/pgupgrade/registration_test.go b/internal/controller/pgupgrade/registration_test.go index dc3a4144bc..22903d8cdb 100644 --- a/internal/controller/pgupgrade/registration_test.go +++ b/internal/controller/pgupgrade/registration_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/pgupgrade/utils.go b/internal/controller/pgupgrade/utils.go index 292107e440..6c92ba5693 100644 --- a/internal/controller/pgupgrade/utils.go +++ b/internal/controller/pgupgrade/utils.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/pgupgrade/world.go b/internal/controller/pgupgrade/world.go index 18d056fe25..c5536e720b 100644 --- a/internal/controller/pgupgrade/world.go +++ b/internal/controller/pgupgrade/world.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/pgupgrade/world_test.go b/internal/controller/pgupgrade/world_test.go index 4aa24f714d..6e1d0942a9 100644 --- a/internal/controller/pgupgrade/world_test.go +++ b/internal/controller/pgupgrade/world_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,8 +13,8 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -34,7 +34,7 @@ func TestPopulateCluster(t *testing.T) { t.Run("NotFound", func(t *testing.T) { cluster := v1beta1.NewPostgresCluster() - expected := apierrors.NewNotFound(schema.GroupResource{}, "name") + expected := apierrors.NewNotFound(runtime.GR{}, "name") world := NewWorld() err := world.populateCluster(cluster, expected) diff --git a/internal/controller/postgrescluster/apply.go b/internal/controller/postgrescluster/apply.go index 2dae1f7d80..ce3d2fb9e5 100644 --- a/internal/controller/postgrescluster/apply.go +++ b/internal/controller/postgrescluster/apply.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/apply_test.go b/internal/controller/postgrescluster/apply_test.go index c163e8a5ab..85dbca995d 100644 --- a/internal/controller/postgrescluster/apply_test.go +++ b/internal/controller/postgrescluster/apply_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/autogrow.go b/internal/controller/postgrescluster/autogrow.go new file mode 100644 index 0000000000..9f17198229 --- /dev/null +++ b/internal/controller/postgrescluster/autogrow.go @@ -0,0 +1,188 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package postgrescluster + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/logging" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// storeDesiredRequest saves the appropriate request value to the PostgresCluster +// status. If the value has grown, create an Event. +func (r *Reconciler) storeDesiredRequest( + ctx context.Context, cluster *v1beta1.PostgresCluster, + volumeType, instanceSetName, desiredRequest, desiredRequestBackup string, +) string { + var current resource.Quantity + var previous resource.Quantity + var err error + log := logging.FromContext(ctx) + + // Parse the desired request from the cluster's status. + if desiredRequest != "" { + current, err = resource.ParseQuantity(desiredRequest) + if err != nil { + log.Error(err, "Unable to parse "+volumeType+" volume request from status ("+ + desiredRequest+") for "+cluster.Name+"/"+instanceSetName) + // If there was an error parsing the value, treat as unset (equivalent to zero). + desiredRequest = "" + current, _ = resource.ParseQuantity("") + + } + } + + // Parse the desired request from the status backup. + if desiredRequestBackup != "" { + previous, err = resource.ParseQuantity(desiredRequestBackup) + if err != nil { + log.Error(err, "Unable to parse "+volumeType+" volume request from status backup ("+ + desiredRequestBackup+") for "+cluster.Name+"/"+instanceSetName) + // If there was an error parsing the value, treat as unset (equivalent to zero). + desiredRequestBackup = "" + previous, _ = resource.ParseQuantity("") + + } + } + + // determine if the appropriate volume limit is set + limitSet := limitIsSet(cluster, volumeType, instanceSetName) + + if limitSet && current.Value() > previous.Value() { + r.Recorder.Eventf(cluster, corev1.EventTypeNormal, "VolumeAutoGrow", + "%s volume expansion to %v requested for %s/%s.", + volumeType, current.String(), cluster.Name, instanceSetName) + } + + // If the desired size was not observed, update with previously stored value. + // This can happen in scenarios where the annotation on the Pod is missing + // such as when the cluster is shutdown or a Pod is in the middle of a restart. + if desiredRequest == "" { + desiredRequest = desiredRequestBackup + } + + return desiredRequest +} + +// limitIsSet determines if the limit is set for a given volume type and returns +// a corresponding boolean value +func limitIsSet(cluster *v1beta1.PostgresCluster, volumeType, instanceSetName string) bool { + + var limitSet bool + + switch volumeType { + + // Cycle through the instance sets to ensure the correct limit is identified. + case "pgData": + for _, specInstance := range cluster.Spec.InstanceSets { + if specInstance.Name == instanceSetName { + limitSet = !specInstance.DataVolumeClaimSpec.Resources.Limits.Storage().IsZero() + } + } + } + // TODO: Add cases for pgWAL and repo volumes + + return limitSet + +} + +// setVolumeSize compares the potential sizes from the instance spec, status +// and limit and sets the appropriate current value. +func (r *Reconciler) setVolumeSize(ctx context.Context, cluster *v1beta1.PostgresCluster, + pvc *corev1.PersistentVolumeClaim, volumeType, instanceSpecName string) { + + log := logging.FromContext(ctx) + + // Store the limit for this instance set. This value will not change below. + volumeLimitFromSpec := pvc.Spec.Resources.Limits.Storage() + + // This value will capture our desired update. + volumeRequestSize := pvc.Spec.Resources.Requests.Storage() + + // A limit of 0 is ignorned, so the volume request is used. + if volumeLimitFromSpec.IsZero() { + return + } + + // If the request value is greater than the set limit, use the limit and issue + // a warning event. + if volumeRequestSize.Value() > volumeLimitFromSpec.Value() { + r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "VolumeRequestOverLimit", + "%s volume request (%v) for %s/%s is greater than set limit (%v). Limit value will be used.", + volumeType, volumeRequestSize, cluster.Name, instanceSpecName, volumeLimitFromSpec) + + pvc.Spec.Resources.Requests = corev1.ResourceList{ + corev1.ResourceStorage: *resource.NewQuantity(volumeLimitFromSpec.Value(), resource.BinarySI), + } + // Otherwise, if the feature gate is not enabled, do not autogrow. + } else if feature.Enabled(ctx, feature.AutoGrowVolumes) { + + // determine the appropriate volume request based on what's set in the status + if dpv, err := getDesiredVolumeSize( + cluster, volumeType, instanceSpecName, volumeRequestSize, + ); err != nil { + log.Error(err, "For "+cluster.Name+"/"+instanceSpecName+ + ": Unable to parse "+volumeType+" volume request: "+dpv) + } + + // If the volume request size is greater than or equal to the limit and the + // limit is not zero, update the request size to the limit value. + // If the user manually requests a lower limit that is smaller than the current + // or requested volume size, it will be ignored in favor of the limit value. + if volumeRequestSize.Value() >= volumeLimitFromSpec.Value() { + + r.Recorder.Eventf(cluster, corev1.EventTypeNormal, "VolumeLimitReached", + "%s volume(s) for %s/%s are at size limit (%v).", volumeType, + cluster.Name, instanceSpecName, volumeLimitFromSpec) + + // If the volume size request is greater than the limit, issue an + // additional event warning. + if volumeRequestSize.Value() > volumeLimitFromSpec.Value() { + r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "DesiredVolumeAboveLimit", + "The desired size (%v) for the %s/%s %s volume(s) is greater than the size limit (%v).", + volumeRequestSize, cluster.Name, instanceSpecName, volumeType, volumeLimitFromSpec) + } + + volumeRequestSize = volumeLimitFromSpec + } + pvc.Spec.Resources.Requests = corev1.ResourceList{ + corev1.ResourceStorage: *resource.NewQuantity(volumeRequestSize.Value(), resource.BinarySI), + } + } +} + +// getDesiredVolumeSize compares the volume request size to the suggested autogrow +// size stored in the status and updates the value when the status value is larger. +func getDesiredVolumeSize(cluster *v1beta1.PostgresCluster, + volumeType, instanceSpecName string, + volumeRequestSize *resource.Quantity) (string, error) { + + switch volumeType { + case "pgData": + for i := range cluster.Status.InstanceSets { + if instanceSpecName == cluster.Status.InstanceSets[i].Name { + for _, dpv := range cluster.Status.InstanceSets[i].DesiredPGDataVolume { + if dpv != "" { + desiredRequest, err := resource.ParseQuantity(dpv) + if err == nil { + if desiredRequest.Value() > volumeRequestSize.Value() { + *volumeRequestSize = desiredRequest + } + } else { + return dpv, err + } + } + } + } + } + // TODO: Add cases for pgWAL and repo volumes (requires relevant status sections) + } + return "", nil +} diff --git a/internal/controller/postgrescluster/autogrow_test.go b/internal/controller/postgrescluster/autogrow_test.go new file mode 100644 index 0000000000..7ec227b373 --- /dev/null +++ b/internal/controller/postgrescluster/autogrow_test.go @@ -0,0 +1,599 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package postgrescluster + +import ( + "context" + "testing" + + "github.com/go-logr/logr/funcr" + "gotest.tools/v3/assert" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crunchydata/postgres-operator/internal/controller/runtime" + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/logging" + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/events" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestStoreDesiredRequest(t *testing.T) { + ctx := context.Background() + + setupLogCapture := func(ctx context.Context) (context.Context, *[]string) { + calls := []string{} + testlog := funcr.NewJSON(func(object string) { + calls = append(calls, object) + }, funcr.Options{ + Verbosity: 1, + }) + return logging.NewContext(ctx, testlog), &calls + } + + cluster := v1beta1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rhino", + Namespace: "test-namespace", + }, + Spec: v1beta1.PostgresClusterSpec{ + InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ + Name: "red", + Replicas: initialize.Int32(1), + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Limits: map[corev1.ResourceName]resource.Quantity{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}}, + }, { + Name: "blue", + Replicas: initialize.Int32(1), + }}}} + + t.Run("BadRequestNoBackup", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + value := reconciler.storeDesiredRequest(ctx, &cluster, "pgData", "red", "woot", "") + + assert.Equal(t, value, "") + assert.Equal(t, len(recorder.Events), 0) + assert.Equal(t, len(*logs), 1) + assert.Assert(t, cmp.Contains((*logs)[0], "Unable to parse pgData volume request from status")) + }) + + t.Run("BadRequestWithBackup", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + value := reconciler.storeDesiredRequest(ctx, &cluster, "pgData", "red", "foo", "1Gi") + + assert.Equal(t, value, "1Gi") + assert.Equal(t, len(recorder.Events), 0) + assert.Equal(t, len(*logs), 1) + assert.Assert(t, cmp.Contains((*logs)[0], "Unable to parse pgData volume request from status (foo) for rhino/red")) + }) + + t.Run("NoLimitNoEvent", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + value := reconciler.storeDesiredRequest(ctx, &cluster, "pgData", "blue", "1Gi", "") + + assert.Equal(t, value, "1Gi") + assert.Equal(t, len(*logs), 0) + assert.Equal(t, len(recorder.Events), 0) + }) + + t.Run("BadBackupRequest", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + value := reconciler.storeDesiredRequest(ctx, &cluster, "pgData", "red", "2Gi", "bar") + + assert.Equal(t, value, "2Gi") + assert.Equal(t, len(*logs), 1) + assert.Assert(t, cmp.Contains((*logs)[0], "Unable to parse pgData volume request from status backup (bar) for rhino/red")) + assert.Equal(t, len(recorder.Events), 1) + assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) + assert.Equal(t, recorder.Events[0].Reason, "VolumeAutoGrow") + assert.Equal(t, recorder.Events[0].Note, "pgData volume expansion to 2Gi requested for rhino/red.") + }) + + t.Run("ValueUpdateWithEvent", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + value := reconciler.storeDesiredRequest(ctx, &cluster, "pgData", "red", "1Gi", "") + + assert.Equal(t, value, "1Gi") + assert.Equal(t, len(*logs), 0) + assert.Equal(t, len(recorder.Events), 1) + assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) + assert.Equal(t, recorder.Events[0].Reason, "VolumeAutoGrow") + assert.Equal(t, recorder.Events[0].Note, "pgData volume expansion to 1Gi requested for rhino/red.") + }) + + t.Run("NoLimitNoEvent", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + value := reconciler.storeDesiredRequest(ctx, &cluster, "pgData", "blue", "1Gi", "") + + assert.Equal(t, value, "1Gi") + assert.Equal(t, len(*logs), 0) + assert.Equal(t, len(recorder.Events), 0) + }) +} + +func TestLimitIsSet(t *testing.T) { + + cluster := v1beta1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rhino", + Namespace: "test-namespace", + }, + Spec: v1beta1.PostgresClusterSpec{ + InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ + Name: "red", + Replicas: initialize.Int32(1), + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Limits: map[corev1.ResourceName]resource.Quantity{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}}, + }, { + Name: "blue", + Replicas: initialize.Int32(1), + }}}} + + testCases := []struct { + tcName string + Voltype string + instanceName string + expected bool + }{{ + tcName: "Limit is set for instance PGDATA volume", + Voltype: "pgData", + instanceName: "red", + expected: true, + }, { + tcName: "Limit is not set for instance PGDATA volume", + Voltype: "pgData", + instanceName: "blue", + expected: false, + }, { + tcName: "Check PGDATA volume for non-existent instance", + Voltype: "pgData", + instanceName: "orange", + expected: false, + }} + + for _, tc := range testCases { + t.Run(tc.tcName, func(t *testing.T) { + + limitSet := limitIsSet(&cluster, tc.Voltype, tc.instanceName) + assert.Check(t, limitSet == tc.expected) + }) + } +} + +func TestSetVolumeSize(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cluster := v1beta1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "elephant", + Namespace: "test-namespace", + }, + Spec: v1beta1.PostgresClusterSpec{ + InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ + Name: "some-instance", + Replicas: initialize.Int32(1), + }}, + }, + } + + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "elephant-some-instance-wxyz-0", + Namespace: cluster.Namespace, + }} + + setupLogCapture := func(ctx context.Context) (context.Context, *[]string) { + calls := []string{} + testlog := funcr.NewJSON(func(object string) { + calls = append(calls, object) + }, funcr.Options{ + Verbosity: 1, + }) + return logging.NewContext(ctx, testlog), &calls + } + + // helper functions + instanceSetSpec := func(request, limit string) *v1beta1.PostgresInstanceSetSpec { + return &v1beta1.PostgresInstanceSetSpec{ + Name: "some-instance", + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + corev1.ResourceStorage: resource.MustParse(request), + }, + Limits: map[corev1.ResourceName]resource.Quantity{ + corev1.ResourceStorage: resource.MustParse(limit), + }}}} + } + + desiredStatus := func(request string) v1beta1.PostgresClusterStatus { + desiredMap := make(map[string]string) + desiredMap["elephant-some-instance-wxyz-0"] = request + return v1beta1.PostgresClusterStatus{ + InstanceSets: []v1beta1.PostgresInstanceSetStatus{{ + Name: "some-instance", + DesiredPGDataVolume: desiredMap, + }}} + } + + t.Run("RequestAboveLimit", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} + spec := instanceSetSpec("4Gi", "3Gi") + pvc.Spec = spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() + + reconciler.setVolumeSize(ctx, &cluster, pvc, "pgData", spec.Name) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + limits: + storage: 3Gi + requests: + storage: 3Gi +`)) + assert.Equal(t, len(*logs), 0) + assert.Equal(t, len(recorder.Events), 1) + assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) + assert.Equal(t, recorder.Events[0].Reason, "VolumeRequestOverLimit") + assert.Equal(t, recorder.Events[0].Note, "pgData volume request (4Gi) for elephant/some-instance is greater than set limit (3Gi). Limit value will be used.") + }) + + t.Run("NoFeatureGate", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} + spec := instanceSetSpec("1Gi", "3Gi") + + desiredMap := make(map[string]string) + desiredMap["elephant-some-instance-wxyz-0"] = "2Gi" + cluster.Status = v1beta1.PostgresClusterStatus{ + InstanceSets: []v1beta1.PostgresInstanceSetStatus{{ + Name: "some-instance", + DesiredPGDataVolume: desiredMap, + }}, + } + + pvc.Spec = spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() + + reconciler.setVolumeSize(ctx, &cluster, pvc, "pgData", spec.Name) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + limits: + storage: 3Gi + requests: + storage: 1Gi + `)) + + assert.Equal(t, len(recorder.Events), 0) + assert.Equal(t, len(*logs), 0) + + // clear status for other tests + cluster.Status = v1beta1.PostgresClusterStatus{} + }) + + t.Run("FeatureEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.AutoGrowVolumes: true, + })) + ctx := feature.NewContext(ctx, gate) + + t.Run("StatusNoLimit", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} + spec := &v1beta1.PostgresInstanceSetSpec{ + Name: "some-instance", + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + Resources: corev1.VolumeResourceRequirements{ + Requests: map[corev1.ResourceName]resource.Quantity{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }}}} + cluster.Status = desiredStatus("2Gi") + pvc.Spec = spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() + + reconciler.setVolumeSize(ctx, &cluster, pvc, "pgData", spec.Name) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + requests: + storage: 1Gi +`)) + assert.Equal(t, len(recorder.Events), 0) + assert.Equal(t, len(*logs), 0) + + // clear status for other tests + cluster.Status = v1beta1.PostgresClusterStatus{} + }) + + t.Run("LimitNoStatus", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} + spec := instanceSetSpec("1Gi", "2Gi") + pvc.Spec = spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() + + reconciler.setVolumeSize(ctx, &cluster, pvc, "pgData", spec.Name) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + limits: + storage: 2Gi + requests: + storage: 1Gi +`)) + assert.Equal(t, len(recorder.Events), 0) + assert.Equal(t, len(*logs), 0) + }) + + t.Run("BadStatusWithLimit", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} + spec := instanceSetSpec("1Gi", "3Gi") + cluster.Status = desiredStatus("NotAValidValue") + pvc.Spec = spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() + + reconciler.setVolumeSize(ctx, &cluster, pvc, "pgData", spec.Name) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + limits: + storage: 3Gi + requests: + storage: 1Gi +`)) + + assert.Equal(t, len(recorder.Events), 0) + assert.Equal(t, len(*logs), 1) + assert.Assert(t, cmp.Contains((*logs)[0], + "For elephant/some-instance: Unable to parse pgData volume request: NotAValidValue")) + }) + + t.Run("StatusWithLimit", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} + spec := instanceSetSpec("1Gi", "3Gi") + cluster.Status = desiredStatus("2Gi") + pvc.Spec = spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() + + reconciler.setVolumeSize(ctx, &cluster, pvc, "pgData", spec.Name) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + limits: + storage: 3Gi + requests: + storage: 2Gi +`)) + assert.Equal(t, len(recorder.Events), 0) + assert.Equal(t, len(*logs), 0) + }) + + t.Run("StatusWithLimitGrowToLimit", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} + spec := instanceSetSpec("1Gi", "2Gi") + cluster.Status = desiredStatus("2Gi") + pvc.Spec = spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() + + reconciler.setVolumeSize(ctx, &cluster, pvc, "pgData", spec.Name) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + limits: + storage: 2Gi + requests: + storage: 2Gi +`)) + + assert.Equal(t, len(*logs), 0) + assert.Equal(t, len(recorder.Events), 1) + assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) + assert.Equal(t, recorder.Events[0].Reason, "VolumeLimitReached") + assert.Equal(t, recorder.Events[0].Note, "pgData volume(s) for elephant/some-instance are at size limit (2Gi).") + }) + + t.Run("DesiredStatusOverLimit", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + ctx, logs := setupLogCapture(ctx) + + pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} + spec := instanceSetSpec("4Gi", "5Gi") + cluster.Status = desiredStatus("10Gi") + pvc.Spec = spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() + + reconciler.setVolumeSize(ctx, &cluster, pvc, "pgData", spec.Name) + + assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` +accessModes: +- ReadWriteOnce +resources: + limits: + storage: 5Gi + requests: + storage: 5Gi +`)) + + assert.Equal(t, len(*logs), 0) + assert.Equal(t, len(recorder.Events), 2) + var found1, found2 bool + for _, event := range recorder.Events { + if event.Reason == "VolumeLimitReached" { + found1 = true + assert.Equal(t, event.Regarding.Name, cluster.Name) + assert.Equal(t, event.Note, "pgData volume(s) for elephant/some-instance are at size limit (5Gi).") + } + if event.Reason == "DesiredVolumeAboveLimit" { + found2 = true + assert.Equal(t, event.Regarding.Name, cluster.Name) + assert.Equal(t, event.Note, + "The desired size (10Gi) for the elephant/some-instance pgData volume(s) is greater than the size limit (5Gi).") + } + } + assert.Assert(t, found1 && found2) + }) + + }) +} + +func TestDetermineDesiredVolumeRequest(t *testing.T) { + t.Parallel() + + cluster := v1beta1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "elephant", + Namespace: "test-namespace", + }, + Spec: v1beta1.PostgresClusterSpec{ + InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ + Name: "some-instance", + Replicas: initialize.Int32(1), + }}, + }, + } + + pgDataStatus := func(request string) v1beta1.PostgresClusterStatus { + desiredMap := make(map[string]string) + desiredMap["elephant-some-instance-wxyz-0"] = request + return v1beta1.PostgresClusterStatus{ + InstanceSets: []v1beta1.PostgresInstanceSetStatus{{ + Name: "some-instance", + DesiredPGDataVolume: desiredMap, + }}} + } + + testCases := []struct { + tcName string + sizeFromStatus string + pvcRequestSize string + volType string + instanceName string + expected string + }{{ + tcName: "Larger size requested", + sizeFromStatus: "3Gi", + pvcRequestSize: "2Gi", + volType: "pgData", + instanceName: "some-instance", + expected: "3Gi", + }, { + tcName: "PVC is desired size", + sizeFromStatus: "2Gi", + pvcRequestSize: "2Gi", + volType: "pgData", + instanceName: "some-instance", + expected: "2Gi", + }, { + tcName: "Original larger than status request", + sizeFromStatus: "1Gi", + pvcRequestSize: "2Gi", + volType: "pgData", + instanceName: "some-instance", + expected: "2Gi", + }, { + tcName: "Instance doesn't exist", + sizeFromStatus: "2Gi", + pvcRequestSize: "1Gi", + volType: "pgData", + instanceName: "not-an-instance", + expected: "1Gi", + }, { + tcName: "Bad Value", + sizeFromStatus: "batman", + pvcRequestSize: "1Gi", + volType: "pgData", + instanceName: "some-instance", + expected: "1Gi", + }} + + for _, tc := range testCases { + t.Run(tc.tcName, func(t *testing.T) { + + cluster.Status = pgDataStatus(tc.sizeFromStatus) + request, err := resource.ParseQuantity(tc.pvcRequestSize) + assert.NilError(t, err) + + dpv, err := getDesiredVolumeSize(&cluster, tc.volType, tc.instanceName, &request) + assert.Equal(t, request.String(), tc.expected) + + if tc.tcName != "Bad Value" { + assert.NilError(t, err) + assert.Assert(t, dpv == "") + } else { + assert.ErrorContains(t, err, "quantities must match the regular expression") + assert.Assert(t, dpv == "batman") + } + }) + } + +} diff --git a/internal/controller/postgrescluster/cluster.go b/internal/controller/postgrescluster/cluster.go index 3ba6eab0e8..2ceb30453a 100644 --- a/internal/controller/postgrescluster/cluster.go +++ b/internal/controller/postgrescluster/cluster.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -15,6 +15,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/patroni" @@ -29,7 +30,7 @@ import ( // files (etc) that apply to the entire cluster. func (r *Reconciler) reconcileClusterConfigMap( ctx context.Context, cluster *v1beta1.PostgresCluster, - pgHBAs postgres.HBAs, pgParameters postgres.Parameters, + pgHBAs *postgres.OrderedHBAs, pgParameters *postgres.ParameterSet, ) (*corev1.ConfigMap, error) { clusterConfigMap := &corev1.ConfigMap{ObjectMeta: naming.ClusterConfigMap(cluster)} clusterConfigMap.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) @@ -44,7 +45,7 @@ func (r *Reconciler) reconcileClusterConfigMap( if err == nil { err = patroni.ClusterConfigMap(ctx, cluster, pgHBAs, pgParameters, - clusterConfigMap) + clusterConfigMap, r.patroniLogSize(ctx, cluster)) } if err == nil { err = errors.WithStack(r.apply(ctx, clusterConfigMap)) @@ -53,6 +54,33 @@ func (r *Reconciler) reconcileClusterConfigMap( return clusterConfigMap, err } +// patroniLogSize attempts to parse the defined log file storage limit, if configured. +// If a value is set, this enables volume based log storage and triggers the +// relevant Patroni configuration. If the value given is less than 25M, the log +// file size storage limit defaults to 25M and an event is triggered. +// If a value is not set, but the OpenTelemetryLogs feature gate is enabled, the +// log file size storage limit will be set to 25M. +func (r *Reconciler) patroniLogSize(ctx context.Context, cluster *v1beta1.PostgresCluster) int64 { + if cluster.Spec.Patroni != nil && cluster.Spec.Patroni.Logging != nil && + cluster.Spec.Patroni.Logging.StorageLimit != nil { + + sizeInBytes := cluster.Spec.Patroni.Logging.StorageLimit.Value() + + if sizeInBytes < 25000000 { + // TODO(validation): Eventually we should be able to remove this in favor of CEL validation. + // - https://kubernetes.io/docs/reference/using-api/cel/ + r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "PatroniLogStorageLimitTooSmall", + "Configured Patroni log storage limit is too small. File size will default to 25M.") + + sizeInBytes = 25000000 + } + return sizeInBytes + } else if collector.OpenTelemetryLogsEnabled(ctx, cluster) { + return 25000000 + } + return 0 +} + // +kubebuilder:rbac:groups="",resources="services",verbs={create,patch} // reconcileClusterPodService writes the Service that can provide stable DNS @@ -240,6 +268,15 @@ func (r *Reconciler) generateClusterReplicaService( } service.Spec.ExternalTrafficPolicy = initialize.FromPointer(spec.ExternalTrafficPolicy) service.Spec.InternalTrafficPolicy = spec.InternalTrafficPolicy + + // Set IPFamilyPolicy and IPFamilies + if spec.IPFamilyPolicy != nil { + service.Spec.IPFamilyPolicy = spec.IPFamilyPolicy + } + if len(spec.IPFamilies) > 0 { + service.Spec.IPFamilies = spec.IPFamilies + } + } service.Spec.Ports = []corev1.ServicePort{servicePort} @@ -281,7 +318,7 @@ func (r *Reconciler) reconcileClusterReplicaService( // `dataSource.pgbackrest` fields func (r *Reconciler) reconcileDataSource(ctx context.Context, cluster *v1beta1.PostgresCluster, observed *observedInstances, - clusterVolumes []corev1.PersistentVolumeClaim, + clusterVolumes []*corev1.PersistentVolumeClaim, rootCA *pki.RootCertificateAuthority, backupsSpecFound bool, ) (bool, error) { diff --git a/internal/controller/postgrescluster/cluster_test.go b/internal/controller/postgrescluster/cluster_test.go index be9e371a56..5fa92d32cf 100644 --- a/internal/controller/postgrescluster/cluster_test.go +++ b/internal/controller/postgrescluster/cluster_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -8,29 +8,29 @@ import ( "context" "testing" - "github.com/pkg/errors" - "go.opentelemetry.io/otel" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/crunchydata/postgres-operator/internal/controller/runtime" + "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/events" "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) -var gvks = []schema.GroupVersionKind{{ +var gvks = []runtime.GVK{{ Group: corev1.SchemeGroupVersion.Group, Version: corev1.SchemeGroupVersion.Version, Kind: "ConfigMapList", @@ -85,13 +85,12 @@ func TestCustomLabels(t *testing.T) { Client: cc, Owner: client.FieldOwner(t.Name()), Recorder: new(record.FakeRecorder), - Tracer: otel.Tracer(t.Name()), } ns := setupNamespace(t, cc) reconcileTestCluster := func(cluster *v1beta1.PostgresCluster) { - assert.NilError(t, errors.WithStack(reconciler.Client.Create(ctx, cluster))) + assert.NilError(t, reconciler.Client.Create(ctx, cluster)) t.Cleanup(func() { // Remove finalizers, if any, so the namespace can terminate. assert.Check(t, client.IgnoreNotFound( @@ -107,28 +106,25 @@ func TestCustomLabels(t *testing.T) { assert.Assert(t, result.Requeue == false) } - getUnstructuredLabels := func(cluster v1beta1.PostgresCluster, u unstructured.Unstructured) (map[string]map[string]string, error) { - var err error + getUnstructuredLabels := func(t *testing.T, cluster *v1beta1.PostgresCluster, u *unstructured.Unstructured) map[string]map[string]string { + t.Helper() labels := map[string]map[string]string{} - if metav1.IsControlledBy(&u, &cluster) { + if metav1.IsControlledBy(u, cluster) { switch u.GetKind() { case "StatefulSet": - var resource appsv1.StatefulSet - err = runtime.DefaultUnstructuredConverter. - FromUnstructured(u.UnstructuredContent(), &resource) + resource, err := runtime.FromUnstructuredObject[appsv1.StatefulSet](u) + assert.NilError(t, err) labels["resource"] = resource.GetLabels() labels["podTemplate"] = resource.Spec.Template.GetLabels() case "Deployment": - var resource appsv1.Deployment - err = runtime.DefaultUnstructuredConverter. - FromUnstructured(u.UnstructuredContent(), &resource) + resource, err := runtime.FromUnstructuredObject[appsv1.Deployment](u) + assert.NilError(t, err) labels["resource"] = resource.GetLabels() labels["podTemplate"] = resource.Spec.Template.GetLabels() case "CronJob": - var resource batchv1.CronJob - err = runtime.DefaultUnstructuredConverter. - FromUnstructured(u.UnstructuredContent(), &resource) + resource, err := runtime.FromUnstructuredObject[batchv1.CronJob](u) + assert.NilError(t, err) labels["resource"] = resource.GetLabels() labels["jobTemplate"] = resource.Spec.JobTemplate.GetLabels() labels["jobPodTemplate"] = resource.Spec.JobTemplate.Spec.Template.GetLabels() @@ -136,13 +132,13 @@ func TestCustomLabels(t *testing.T) { labels["resource"] = u.GetLabels() } } - return labels, err + return labels } t.Run("Cluster", func(t *testing.T) { cluster := testCluster() - cluster.ObjectMeta.Name = "global-cluster" - cluster.ObjectMeta.Namespace = ns.Name + cluster.Name = "global-cluster" + cluster.Namespace = ns.Name cluster.Spec.InstanceSets = []v1beta1.PostgresInstanceSetSpec{{ Name: "daisy-instance1", Replicas: initialize.Int32(1), @@ -176,10 +172,8 @@ func TestCustomLabels(t *testing.T) { client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector})) - for i := range uList.Items { - u := uList.Items[i] - labels, err := getUnstructuredLabels(*cluster, u) - assert.NilError(t, err) + for _, u := range uList.Items { + labels := getUnstructuredLabels(t, cluster, &u) for resourceType, resourceLabels := range labels { t.Run(u.GetKind()+"/"+u.GetName()+"/"+resourceType, func(t *testing.T) { assert.Equal(t, resourceLabels["my.cluster.label"], "daisy") @@ -191,8 +185,8 @@ func TestCustomLabels(t *testing.T) { t.Run("Instance", func(t *testing.T) { cluster := testCluster() - cluster.ObjectMeta.Name = "instance-cluster" - cluster.ObjectMeta.Namespace = ns.Name + cluster.Name = "instance-cluster" + cluster.Namespace = ns.Name cluster.Spec.InstanceSets = []v1beta1.PostgresInstanceSetSpec{{ Name: "max-instance", Replicas: initialize.Int32(1), @@ -226,11 +220,8 @@ func TestCustomLabels(t *testing.T) { client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector})) - for i := range uList.Items { - u := uList.Items[i] - - labels, err := getUnstructuredLabels(*cluster, u) - assert.NilError(t, err) + for _, u := range uList.Items { + labels := getUnstructuredLabels(t, cluster, &u) for resourceType, resourceLabels := range labels { t.Run(u.GetKind()+"/"+u.GetName()+"/"+resourceType, func(t *testing.T) { assert.Equal(t, resourceLabels["my.instance.label"], set.Metadata.Labels["my.instance.label"]) @@ -245,8 +236,8 @@ func TestCustomLabels(t *testing.T) { t.Run("PGBackRest", func(t *testing.T) { cluster := testCluster() - cluster.ObjectMeta.Name = "pgbackrest-cluster" - cluster.ObjectMeta.Namespace = ns.Name + cluster.Name = "pgbackrest-cluster" + cluster.Namespace = ns.Name cluster.Spec.Backups.PGBackRest.Metadata = &v1beta1.Metadata{ Labels: map[string]string{"my.pgbackrest.label": "lucy"}, } @@ -276,11 +267,8 @@ func TestCustomLabels(t *testing.T) { client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector})) - for i := range uList.Items { - u := uList.Items[i] - - labels, err := getUnstructuredLabels(*cluster, u) - assert.NilError(t, err) + for _, u := range uList.Items { + labels := getUnstructuredLabels(t, cluster, &u) for resourceType, resourceLabels := range labels { t.Run(u.GetKind()+"/"+u.GetName()+"/"+resourceType, func(t *testing.T) { assert.Equal(t, resourceLabels["my.pgbackrest.label"], "lucy") @@ -292,8 +280,8 @@ func TestCustomLabels(t *testing.T) { t.Run("PGBouncer", func(t *testing.T) { cluster := testCluster() - cluster.ObjectMeta.Name = "pgbouncer-cluster" - cluster.ObjectMeta.Namespace = ns.Name + cluster.Name = "pgbouncer-cluster" + cluster.Namespace = ns.Name cluster.Spec.Proxy.PGBouncer.Metadata = &v1beta1.Metadata{ Labels: map[string]string{"my.pgbouncer.label": "lucy"}, } @@ -314,11 +302,8 @@ func TestCustomLabels(t *testing.T) { client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector})) - for i := range uList.Items { - u := uList.Items[i] - - labels, err := getUnstructuredLabels(*cluster, u) - assert.NilError(t, err) + for _, u := range uList.Items { + labels := getUnstructuredLabels(t, cluster, &u) for resourceType, resourceLabels := range labels { t.Run(u.GetKind()+"/"+u.GetName()+"/"+resourceType, func(t *testing.T) { assert.Equal(t, resourceLabels["my.pgbouncer.label"], "lucy") @@ -338,13 +323,12 @@ func TestCustomAnnotations(t *testing.T) { Client: cc, Owner: client.FieldOwner(t.Name()), Recorder: new(record.FakeRecorder), - Tracer: otel.Tracer(t.Name()), } ns := setupNamespace(t, cc) reconcileTestCluster := func(cluster *v1beta1.PostgresCluster) { - assert.NilError(t, errors.WithStack(reconciler.Client.Create(ctx, cluster))) + assert.NilError(t, reconciler.Client.Create(ctx, cluster)) t.Cleanup(func() { // Remove finalizers, if any, so the namespace can terminate. assert.Check(t, client.IgnoreNotFound( @@ -360,28 +344,25 @@ func TestCustomAnnotations(t *testing.T) { assert.Assert(t, result.Requeue == false) } - getUnstructuredAnnotations := func(cluster v1beta1.PostgresCluster, u unstructured.Unstructured) (map[string]map[string]string, error) { - var err error + getUnstructuredAnnotations := func(t *testing.T, cluster *v1beta1.PostgresCluster, u *unstructured.Unstructured) map[string]map[string]string { + t.Helper() annotations := map[string]map[string]string{} - if metav1.IsControlledBy(&u, &cluster) { + if metav1.IsControlledBy(u, cluster) { switch u.GetKind() { case "StatefulSet": - var resource appsv1.StatefulSet - err = runtime.DefaultUnstructuredConverter. - FromUnstructured(u.UnstructuredContent(), &resource) + resource, err := runtime.FromUnstructuredObject[appsv1.StatefulSet](u) + assert.NilError(t, err) annotations["resource"] = resource.GetAnnotations() annotations["podTemplate"] = resource.Spec.Template.GetAnnotations() case "Deployment": - var resource appsv1.Deployment - err = runtime.DefaultUnstructuredConverter. - FromUnstructured(u.UnstructuredContent(), &resource) + resource, err := runtime.FromUnstructuredObject[appsv1.Deployment](u) + assert.NilError(t, err) annotations["resource"] = resource.GetAnnotations() annotations["podTemplate"] = resource.Spec.Template.GetAnnotations() case "CronJob": - var resource batchv1.CronJob - err = runtime.DefaultUnstructuredConverter. - FromUnstructured(u.UnstructuredContent(), &resource) + resource, err := runtime.FromUnstructuredObject[batchv1.CronJob](u) + assert.NilError(t, err) annotations["resource"] = resource.GetAnnotations() annotations["jobTemplate"] = resource.Spec.JobTemplate.GetAnnotations() annotations["jobPodTemplate"] = resource.Spec.JobTemplate.Spec.Template.GetAnnotations() @@ -389,13 +370,13 @@ func TestCustomAnnotations(t *testing.T) { annotations["resource"] = u.GetAnnotations() } } - return annotations, err + return annotations } t.Run("Cluster", func(t *testing.T) { cluster := testCluster() - cluster.ObjectMeta.Name = "global-cluster" - cluster.ObjectMeta.Namespace = ns.Name + cluster.Name = "global-cluster" + cluster.Namespace = ns.Name cluster.Spec.InstanceSets = []v1beta1.PostgresInstanceSetSpec{{ Name: "daisy-instance1", Replicas: initialize.Int32(1), @@ -430,10 +411,8 @@ func TestCustomAnnotations(t *testing.T) { client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector})) - for i := range uList.Items { - u := uList.Items[i] - annotations, err := getUnstructuredAnnotations(*cluster, u) - assert.NilError(t, err) + for _, u := range uList.Items { + annotations := getUnstructuredAnnotations(t, cluster, &u) for resourceType, resourceAnnotations := range annotations { t.Run(u.GetKind()+"/"+u.GetName()+"/"+resourceType, func(t *testing.T) { assert.Equal(t, resourceAnnotations["my.cluster.annotation"], "daisy") @@ -445,8 +424,8 @@ func TestCustomAnnotations(t *testing.T) { t.Run("Instance", func(t *testing.T) { cluster := testCluster() - cluster.ObjectMeta.Name = "instance-cluster" - cluster.ObjectMeta.Namespace = ns.Name + cluster.Name = "instance-cluster" + cluster.Namespace = ns.Name cluster.Spec.InstanceSets = []v1beta1.PostgresInstanceSetSpec{{ Name: "max-instance", Replicas: initialize.Int32(1), @@ -480,11 +459,8 @@ func TestCustomAnnotations(t *testing.T) { client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector})) - for i := range uList.Items { - u := uList.Items[i] - - annotations, err := getUnstructuredAnnotations(*cluster, u) - assert.NilError(t, err) + for _, u := range uList.Items { + annotations := getUnstructuredAnnotations(t, cluster, &u) for resourceType, resourceAnnotations := range annotations { t.Run(u.GetKind()+"/"+u.GetName()+"/"+resourceType, func(t *testing.T) { assert.Equal(t, resourceAnnotations["my.instance.annotation"], set.Metadata.Annotations["my.instance.annotation"]) @@ -499,8 +475,8 @@ func TestCustomAnnotations(t *testing.T) { t.Run("PGBackRest", func(t *testing.T) { cluster := testCluster() - cluster.ObjectMeta.Name = "pgbackrest-cluster" - cluster.ObjectMeta.Namespace = ns.Name + cluster.Name = "pgbackrest-cluster" + cluster.Namespace = ns.Name cluster.Spec.Backups.PGBackRest.Metadata = &v1beta1.Metadata{ Annotations: map[string]string{"my.pgbackrest.annotation": "lucy"}, } @@ -530,11 +506,8 @@ func TestCustomAnnotations(t *testing.T) { client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector})) - for i := range uList.Items { - u := uList.Items[i] - - annotations, err := getUnstructuredAnnotations(*cluster, u) - assert.NilError(t, err) + for _, u := range uList.Items { + annotations := getUnstructuredAnnotations(t, cluster, &u) for resourceType, resourceAnnotations := range annotations { t.Run(u.GetKind()+"/"+u.GetName()+"/"+resourceType, func(t *testing.T) { assert.Equal(t, resourceAnnotations["my.pgbackrest.annotation"], "lucy") @@ -546,8 +519,8 @@ func TestCustomAnnotations(t *testing.T) { t.Run("PGBouncer", func(t *testing.T) { cluster := testCluster() - cluster.ObjectMeta.Name = "pgbouncer-cluster" - cluster.ObjectMeta.Namespace = ns.Name + cluster.Name = "pgbouncer-cluster" + cluster.Namespace = ns.Name cluster.Spec.Proxy.PGBouncer.Metadata = &v1beta1.Metadata{ Annotations: map[string]string{"my.pgbouncer.annotation": "lucy"}, } @@ -568,11 +541,8 @@ func TestCustomAnnotations(t *testing.T) { client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector})) - for i := range uList.Items { - u := uList.Items[i] - - annotations, err := getUnstructuredAnnotations(*cluster, u) - assert.NilError(t, err) + for _, u := range uList.Items { + annotations := getUnstructuredAnnotations(t, cluster, &u) for resourceType, resourceAnnotations := range annotations { t.Run(u.GetKind()+"/"+u.GetName()+"/"+resourceType, func(t *testing.T) { assert.Equal(t, resourceAnnotations["my.pgbouncer.annotation"], "lucy") @@ -798,12 +768,12 @@ type: ClusterIP assert.NilError(t, err) // Annotations present in the metadata. - assert.Assert(t, cmp.MarshalMatches(service.ObjectMeta.Annotations, ` + assert.Assert(t, cmp.MarshalMatches(service.Annotations, ` some: note `)) // Labels present in the metadata. - assert.Assert(t, cmp.MarshalMatches(service.ObjectMeta.Labels, ` + assert.Assert(t, cmp.MarshalMatches(service.Labels, ` happy: label postgres-operator.crunchydata.com/cluster: pg2 postgres-operator.crunchydata.com/role: replica @@ -816,3 +786,120 @@ postgres-operator.crunchydata.com/role: replica `)) }) } + +func TestPatroniLogSize(t *testing.T) { + ctx := context.Background() + + oneHundredMeg, err := resource.ParseQuantity("100M") + assert.NilError(t, err) + + tooSmall, err := resource.ParseQuantity("1k") + assert.NilError(t, err) + + cluster := v1beta1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sometest", + Namespace: "test-namespace", + }, + Spec: v1beta1.PostgresClusterSpec{}} + + t.Run("Default", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + + size := reconciler.patroniLogSize(ctx, &cluster) + + assert.Equal(t, size, int64(0)) + assert.Equal(t, len(recorder.Events), 0) + }) + + t.Run("NoSize", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + + cluster.Spec.Patroni = &v1beta1.PatroniSpec{ + Logging: &v1beta1.PatroniLogConfig{}} + + size := reconciler.patroniLogSize(ctx, &cluster) + + assert.Equal(t, size, int64(0)) + assert.Equal(t, len(recorder.Events), 0) + }) + + t.Run("ValidSize", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + + cluster.Spec.Patroni = &v1beta1.PatroniSpec{ + Logging: &v1beta1.PatroniLogConfig{ + StorageLimit: &oneHundredMeg, + }} + + size := reconciler.patroniLogSize(ctx, &cluster) + + assert.Equal(t, size, int64(100000000)) + assert.Equal(t, len(recorder.Events), 0) + }) + + t.Run("BadSize", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + + cluster.Spec.Patroni = &v1beta1.PatroniSpec{ + Logging: &v1beta1.PatroniLogConfig{ + StorageLimit: &tooSmall, + }} + + size := reconciler.patroniLogSize(ctx, &cluster) + + assert.Equal(t, size, int64(25000000)) + assert.Equal(t, len(recorder.Events), 1) + assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) + assert.Equal(t, recorder.Events[0].Reason, "PatroniLogStorageLimitTooSmall") + assert.Equal(t, recorder.Events[0].Note, "Configured Patroni log storage limit is too small. File size will default to 25M.") + }) + + t.Run("SizeUnsetOtelLogsEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(ctx, gate) + + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + + cluster.Spec.Patroni = nil + require.UnmarshalInto(t, &cluster.Spec, `{ + instrumentation: { + logs: { retentionPeriod: 5h }, + }, + }`) + + size := reconciler.patroniLogSize(ctx, &cluster) + + assert.Equal(t, size, int64(25000000)) + assert.Equal(t, len(recorder.Events), 0) + }) + + t.Run("SizeSetOtelLogsEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(ctx, gate) + + recorder := events.NewRecorder(t, runtime.Scheme) + reconciler := &Reconciler{Recorder: recorder} + + cluster.Spec.Patroni = &v1beta1.PatroniSpec{ + Logging: &v1beta1.PatroniLogConfig{ + StorageLimit: &oneHundredMeg, + }} + + size := reconciler.patroniLogSize(ctx, &cluster) + + assert.Equal(t, size, int64(100000000)) + assert.Equal(t, len(recorder.Events), 0) + }) +} diff --git a/internal/controller/postgrescluster/controller.go b/internal/controller/postgrescluster/controller.go index d459d30a10..bbe141c0b4 100644 --- a/internal/controller/postgrescluster/controller.go +++ b/internal/controller/postgrescluster/controller.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -11,18 +11,15 @@ import ( "io" "time" - "go.opentelemetry.io/otel/trace" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/equality" - apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" - "k8s.io/client-go/discovery" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" @@ -30,17 +27,16 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/kubernetes" "github.com/crunchydata/postgres-operator/internal/logging" - "github.com/crunchydata/postgres-operator/internal/pgaudit" - "github.com/crunchydata/postgres-operator/internal/pgbackrest" - "github.com/crunchydata/postgres-operator/internal/pgbouncer" - "github.com/crunchydata/postgres-operator/internal/pgmonitor" "github.com/crunchydata/postgres-operator/internal/pki" "github.com/crunchydata/postgres-operator/internal/postgres" "github.com/crunchydata/postgres-operator/internal/registration" + "github.com/crunchydata/postgres-operator/internal/tracing" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -51,17 +47,14 @@ const ( // Reconciler holds resources for the PostgresCluster reconciler type Reconciler struct { - Client client.Client - DiscoveryClient *discovery.DiscoveryClient - IsOpenShift bool - Owner client.FieldOwner - PodExec func( + Client client.Client + Owner client.FieldOwner + PodExec func( ctx context.Context, namespace, pod, container string, stdin io.Reader, stdout, stderr io.Writer, command ...string, ) error Recorder record.EventRecorder Registration registration.Registration - Tracer trace.Tracer } // +kubebuilder:rbac:groups="",resources="events",verbs={create,patch} @@ -72,7 +65,7 @@ type Reconciler struct { func (r *Reconciler) Reconcile( ctx context.Context, request reconcile.Request) (reconcile.Result, error, ) { - ctx, span := r.Tracer.Start(ctx, "Reconcile") + ctx, span := tracing.Start(ctx, "reconcile-postgrescluster") log := logging.FromContext(ctx) defer span.End() @@ -84,9 +77,8 @@ func (r *Reconciler) Reconcile( // cluster is deleted. if err = client.IgnoreNotFound(err); err != nil { log.Error(err, "unable to fetch PostgresCluster") - span.RecordError(err) } - return runtime.ErrorWithBackoff(err) + return runtime.ErrorWithBackoff(tracing.Escape(span, err)) } // Set any defaults that may not have been stored in the API. No DeepCopy @@ -94,8 +86,9 @@ func (r *Reconciler) Reconcile( // from its cache. cluster.Default() + // TODO(openshift): Separate this into more specific detections elsewhere. if cluster.Spec.OpenShift == nil { - cluster.Spec.OpenShift = &r.IsOpenShift + cluster.Spec.OpenShift = initialize.Bool(kubernetes.IsOpenShift(ctx)) } // Keep a copy of cluster prior to any manipulations. @@ -110,9 +103,8 @@ func (r *Reconciler) Reconcile( // Check for and handle deletion of cluster. Return early if it is being // deleted or there was an error. if result, err := r.handleDelete(ctx, cluster); err != nil { - span.RecordError(err) log.Error(err, "deleting") - return runtime.ErrorWithBackoff(err) + return runtime.ErrorWithBackoff(tracing.Escape(span, err)) } else if result != nil { if log := log.V(1); log.Enabled() { @@ -133,9 +125,19 @@ func (r *Reconciler) Reconcile( // specifically allow reconciliation if the cluster is shutdown to // facilitate upgrades, otherwise return if !initialize.FromPointer(cluster.Spec.Shutdown) { - return runtime.ErrorWithBackoff(err) + return runtime.ErrorWithBackoff(tracing.Escape(span, err)) } } + // Issue Warning Event if postgres version is EOL according to PostgreSQL: + // https://www.postgresql.org/support/versioning/ + currentTime := time.Now() + if postgres.ReleaseIsFinal(cluster.Spec.PostgresVersion, currentTime) { + r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "EndOfLifePostgresVersion", + "The last minor version of Postgres %[1]v has been released."+ + " PG %[1]v will no longer receive updates. We recommend upgrading."+ + " See https://www.postgresql.org/support/versioning", + cluster.Spec.PostgresVersion) + } if cluster.Spec.Standby != nil && cluster.Spec.Standby.Enabled && @@ -147,14 +149,14 @@ func (r *Reconciler) Reconcile( path := field.NewPath("spec", "standby") err := field.Invalid(path, cluster.Name, "Standby requires a host or repoName to be enabled") r.Recorder.Event(cluster, corev1.EventTypeWarning, "InvalidStandbyConfiguration", err.Error()) - return runtime.ErrorWithBackoff(err) + return runtime.ErrorWithBackoff(tracing.Escape(span, err)) } var ( clusterConfigMap *corev1.ConfigMap clusterReplicationSecret *corev1.Secret clusterPodService *corev1.Service - clusterVolumes []corev1.PersistentVolumeClaim + clusterVolumes []*corev1.PersistentVolumeClaim instanceServiceAccount *corev1.ServiceAccount instances *observedInstances patroniLeaderService *corev1.Service @@ -201,7 +203,7 @@ func (r *Reconciler) Reconcile( ObservedGeneration: cluster.GetGeneration(), }) - return runtime.ErrorWithBackoff(patchClusterStatus()) + return runtime.ErrorWithBackoff(tracing.Escape(span, patchClusterStatus())) } else { meta.RemoveStatusCondition(&cluster.Status.Conditions, v1beta1.PostgresClusterProgressing) } @@ -221,23 +223,16 @@ func (r *Reconciler) Reconcile( ObservedGeneration: cluster.GetGeneration(), }) - return runtime.ErrorWithBackoff(patchClusterStatus()) + return runtime.ErrorWithBackoff(tracing.Escape(span, patchClusterStatus())) } else { meta.RemoveStatusCondition(&cluster.Status.Conditions, v1beta1.PostgresClusterProgressing) } } - pgHBAs := postgres.NewHBAs() - pgmonitor.PostgreSQLHBAs(cluster, &pgHBAs) - pgbouncer.PostgreSQL(cluster, &pgHBAs) - - pgParameters := postgres.NewParameters() - pgaudit.PostgreSQLParameters(&pgParameters) - pgbackrest.PostgreSQL(cluster, &pgParameters, backupsSpecFound) - pgmonitor.PostgreSQLParameters(cluster, &pgParameters) + pgHBAs := r.generatePostgresHBAs(ctx, cluster) + pgParameters := r.generatePostgresParameters(ctx, cluster, backupsSpecFound) - // Set huge_pages = try if a hugepages resource limit > 0, otherwise set "off" - postgres.SetHugePages(cluster, &pgParameters) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) if err == nil { rootCA, err = r.reconcileRootCertificate(ctx, cluster) @@ -252,7 +247,8 @@ func (r *Reconciler) Reconcile( // return is no longer needed, and reconciliation can proceed normally. returnEarly, err := r.reconcileDirMoveJobs(ctx, cluster) if err != nil || returnEarly { - return runtime.ErrorWithBackoff(errors.Join(err, patchClusterStatus())) + return runtime.ErrorWithBackoff(tracing.Escape(span, + errors.Join(err, patchClusterStatus()))) } } if err == nil { @@ -302,7 +298,7 @@ func (r *Reconciler) Reconcile( // can proceed normally. returnEarly, err := r.reconcileDataSource(ctx, cluster, instances, clusterVolumes, rootCA, backupsSpecFound) if err != nil || returnEarly { - return runtime.ErrorWithBackoff(errors.Join(err, patchClusterStatus())) + return runtime.ErrorWithBackoff(tracing.Escape(span, errors.Join(err, patchClusterStatus()))) } } if err == nil { @@ -343,7 +339,7 @@ func (r *Reconciler) Reconcile( ctx, cluster, clusterConfigMap, clusterReplicationSecret, rootCA, clusterPodService, instanceServiceAccount, instances, patroniLeaderService, primaryCertificate, clusterVolumes, exporterQueriesConfig, exporterWebConfig, - backupsSpecFound, + backupsSpecFound, otelConfig, ) } @@ -374,7 +370,7 @@ func (r *Reconciler) Reconcile( err = r.reconcilePGBouncer(ctx, cluster, instances, primaryCertificate, rootCA) } if err == nil { - err = r.reconcilePGMonitor(ctx, cluster, instances, monitoringSecret) + err = r.reconcilePGMonitorExporter(ctx, cluster, instances, monitoringSecret) } if err == nil { err = r.reconcileDatabaseInitSQL(ctx, cluster, instances) @@ -394,7 +390,7 @@ func (r *Reconciler) Reconcile( log.V(1).Info("reconciled cluster") - return result, errors.Join(err, patchClusterStatus()) + return result, tracing.Escape(span, errors.Join(err, patchClusterStatus())) } // deleteControlled safely deletes object when it is controlled by cluster. @@ -472,14 +468,6 @@ func (r *Reconciler) SetupWithManager(mgr manager.Manager) error { } } - if r.DiscoveryClient == nil { - var err error - r.DiscoveryClient, err = discovery.NewDiscoveryClientForConfig(mgr.GetConfig()) - if err != nil { - return err - } - } - return builder.ControllerManagedBy(mgr). For(&v1beta1.PostgresCluster{}). Owns(&corev1.ConfigMap{}). @@ -500,28 +488,3 @@ func (r *Reconciler) SetupWithManager(mgr manager.Manager) error { r.controllerRefHandlerFuncs()). // watch all StatefulSets Complete(r) } - -// GroupVersionKindExists checks to see whether a given Kind for a given -// GroupVersion exists in the Kubernetes API Server. -func (r *Reconciler) GroupVersionKindExists(groupVersion, kind string) (*bool, error) { - if r.DiscoveryClient == nil { - return initialize.Bool(false), nil - } - - resourceList, err := r.DiscoveryClient.ServerResourcesForGroupVersion(groupVersion) - if err != nil { - if apierrors.IsNotFound(err) { - return initialize.Bool(false), nil - } - - return nil, err - } - - for _, resource := range resourceList.APIResources { - if resource.Kind == kind { - return initialize.Bool(true), nil - } - } - - return initialize.Bool(false), nil -} diff --git a/internal/controller/postgrescluster/controller_ref_manager.go b/internal/controller/postgrescluster/controller_ref_manager.go index 8c4a34189f..d229728b12 100644 --- a/internal/controller/postgrescluster/controller_ref_manager.go +++ b/internal/controller/postgrescluster/controller_ref_manager.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -15,6 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" "github.com/crunchydata/postgres-operator/internal/kubeapi" "github.com/crunchydata/postgres-operator/internal/logging" @@ -87,7 +88,7 @@ func (r *Reconciler) claimObject(ctx context.Context, postgresCluster *v1beta1.P // At this point the resource has no controller ref and is therefore an orphan. Ignore if // either the PostgresCluster resource or the orphaned resource is being deleted, or if the selector - // for the orphaned resource doesn't doesn't include the proper PostgresCluster label + // for the orphaned resource doesn't include the proper PostgresCluster label _, hasPGClusterLabel := obj.GetLabels()[naming.LabelCluster] if postgresCluster.GetDeletionTimestamp() != nil || !hasPGClusterLabel { return nil @@ -185,17 +186,17 @@ func (r *Reconciler) controllerRefHandlerFuncs() *handler.Funcs { errMsg := "managing StatefulSet controller refs" return &handler.Funcs{ - CreateFunc: func(ctx context.Context, updateEvent event.CreateEvent, workQueue workqueue.RateLimitingInterface) { + CreateFunc: func(ctx context.Context, updateEvent event.CreateEvent, workQueue workqueue.TypedRateLimitingInterface[reconcile.Request]) { if err := r.manageControllerRefs(ctx, updateEvent.Object); err != nil { log.Error(err, errMsg) } }, - UpdateFunc: func(ctx context.Context, updateEvent event.UpdateEvent, workQueue workqueue.RateLimitingInterface) { + UpdateFunc: func(ctx context.Context, updateEvent event.UpdateEvent, workQueue workqueue.TypedRateLimitingInterface[reconcile.Request]) { if err := r.manageControllerRefs(ctx, updateEvent.ObjectNew); err != nil { log.Error(err, errMsg) } }, - DeleteFunc: func(ctx context.Context, updateEvent event.DeleteEvent, workQueue workqueue.RateLimitingInterface) { + DeleteFunc: func(ctx context.Context, updateEvent event.DeleteEvent, workQueue workqueue.TypedRateLimitingInterface[reconcile.Request]) { if err := r.manageControllerRefs(ctx, updateEvent.Object); err != nil { log.Error(err, errMsg) } diff --git a/internal/controller/postgrescluster/controller_ref_manager_test.go b/internal/controller/postgrescluster/controller_ref_manager_test.go index 8543fe390d..fa8450c5d9 100644 --- a/internal/controller/postgrescluster/controller_ref_manager_test.go +++ b/internal/controller/postgrescluster/controller_ref_manager_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/controller_test.go b/internal/controller/postgrescluster/controller_test.go index e6fdc5cb86..36759cd784 100644 --- a/internal/controller/postgrescluster/controller_test.go +++ b/internal/controller/postgrescluster/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,9 +13,7 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" . "github.com/onsi/gomega/gstruct" - "github.com/pkg/errors" - - "go.opentelemetry.io/otel" + "github.com/pkg/errors" //nolint:depguard // This legacy test covers so much code, it logs the origin of unexpected errors. "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -138,7 +136,6 @@ var _ = Describe("PostgresCluster Reconciler", func() { test.Reconciler.Owner = "asdf" test.Reconciler.Recorder = test.Recorder test.Reconciler.Registration = nil - test.Reconciler.Tracer = otel.Tracer("asdf") }) AfterEach(func() { @@ -352,7 +349,7 @@ spec: MatchFields(IgnoreExtras, Fields{ "Manager": Equal(string(test.Reconciler.Owner)), "FieldsV1": PointTo(MatchAllFields(Fields{ - "Raw": WithTransform(func(in []byte) (out map[string]interface{}) { + "Raw": WithTransform(func(in []byte) (out map[string]any) { Expect(yaml.Unmarshal(in, &out)).To(Succeed()) return out }, MatchAllKeys(Keys{ @@ -370,7 +367,7 @@ spec: MatchFields(IgnoreExtras, Fields{ "Manager": Equal(string(test.Reconciler.Owner)), "FieldsV1": PointTo(MatchAllFields(Fields{ - "Raw": WithTransform(func(in []byte) (out map[string]interface{}) { + "Raw": WithTransform(func(in []byte) (out map[string]any) { Expect(yaml.Unmarshal(in, &out)).To(Succeed()) return out }, MatchAllKeys(Keys{ @@ -383,7 +380,7 @@ spec: MatchFields(IgnoreExtras, Fields{ "Manager": Equal(string(test.Reconciler.Owner)), "FieldsV1": PointTo(MatchAllFields(Fields{ - "Raw": WithTransform(func(in []byte) (out map[string]interface{}) { + "Raw": WithTransform(func(in []byte) (out map[string]any) { Expect(yaml.Unmarshal(in, &out)).To(Succeed()) return out }, MatchAllKeys(Keys{ @@ -556,4 +553,66 @@ spec: Expect(instance.Spec.Replicas).To(PointTo(BeEquivalentTo(1))) }) }) + + Context("Postgres version EOL", func() { + var cluster *v1beta1.PostgresCluster + + BeforeEach(func() { + cluster = create(` +metadata: + name: old-postgres +spec: + postgresVersion: 11 + image: postgres + instances: + - name: instance1 + dataVolumeClaimSpec: + accessModes: + - "ReadWriteMany" + resources: + requests: + storage: 1Gi + backups: + pgbackrest: + image: pgbackrest + repos: + - name: repo1 + volume: + volumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi +`) + Expect(reconcile(cluster)).To(BeZero()) + }) + + AfterEach(func() { + ctx := context.Background() + + if cluster != nil { + Expect(client.IgnoreNotFound( + suite.Client.Delete(ctx, cluster), + )).To(Succeed()) + + // Remove finalizers, if any, so the namespace can terminate. + Expect(client.IgnoreNotFound( + suite.Client.Patch(ctx, cluster, client.RawPatch( + client.Merge.Type(), []byte(`{"metadata":{"finalizers":[]}}`))), + )).To(Succeed()) + } + }) + + Specify("Postgres EOL Warning Event", func() { + existing := &v1beta1.PostgresCluster{} + Expect(suite.Client.Get( + context.Background(), client.ObjectKeyFromObject(cluster), existing, + )).To(Succeed()) + + event, ok := <-test.Recorder.Events + Expect(ok).To(BeTrue()) + Expect(event).To(ContainSubstring("PG 11 will no longer receive updates. We recommend upgrading.")) + }) + }) }) diff --git a/internal/controller/postgrescluster/delete.go b/internal/controller/postgrescluster/delete.go index 63fc007f40..a1a4d322dd 100644 --- a/internal/controller/postgrescluster/delete.go +++ b/internal/controller/postgrescluster/delete.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/helpers_test.go b/internal/controller/postgrescluster/helpers_test.go index 0536b466d4..4542f651a9 100644 --- a/internal/controller/postgrescluster/helpers_test.go +++ b/internal/controller/postgrescluster/helpers_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -90,9 +90,9 @@ func setupNamespace(t testing.TB, cc client.Client) *corev1.Namespace { return require.Namespace(t, cc) } -func testVolumeClaimSpec() corev1.PersistentVolumeClaimSpec { +func testVolumeClaimSpec() v1beta1.VolumeClaimSpec { // Defines a volume claim spec that can be used to create instances - return corev1.PersistentVolumeClaimSpec{ + return v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{ Requests: map[corev1.ResourceName]resource.Quantity{ diff --git a/internal/controller/postgrescluster/instance.go b/internal/controller/postgrescluster/instance.go index 66321cc738..97b035c04d 100644 --- a/internal/controller/postgrescluster/instance.go +++ b/internal/controller/postgrescluster/instance.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -8,25 +8,23 @@ import ( "context" "fmt" "io" + "maps" "sort" "strings" "time" "github.com/pkg/errors" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/sets" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/feature" @@ -37,6 +35,7 @@ import ( "github.com/crunchydata/postgres-operator/internal/pgbackrest" "github.com/crunchydata/postgres-operator/internal/pki" "github.com/crunchydata/postgres-operator/internal/postgres" + "github.com/crunchydata/postgres-operator/internal/tracing" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -134,7 +133,8 @@ func (i Instance) IsWritable() (writable, known bool) { // TODO(cbandy): Update this to consider when Patroni is paused. - return strings.HasPrefix(member[role:], `"role":"master"`), true + return strings.HasPrefix(member[role:], `"role":"master"`) || + strings.HasPrefix(member[role:], `"role":"primary"`), true } // PodMatchesPodTemplate returns whether or not the Pod for this instance @@ -321,9 +321,7 @@ func (r *Reconciler) observeInstances( if autogrow { for _, statusIS := range cluster.Status.InstanceSets { if statusIS.DesiredPGDataVolume != nil { - for k, v := range statusIS.DesiredPGDataVolume { - previousDesiredRequests[k] = v - } + maps.Copy(previousDesiredRequests, statusIS.DesiredPGDataVolume) } } } @@ -335,7 +333,8 @@ func (r *Reconciler) observeInstances( status.DesiredPGDataVolume = make(map[string]string) for _, instance := range observed.bySet[name] { - status.Replicas += int32(len(instance.Pods)) //nolint:gosec + //nolint:gosec // This slice is always small. + status.Replicas += int32(len(instance.Pods)) if ready, known := instance.IsReady(); known && ready { status.ReadyReplicas++ @@ -357,10 +356,13 @@ func (r *Reconciler) observeInstances( } } - // If autogrow is enabled, get the desired volume size for each instance. + // If autogrow is enabled, determine the desired volume size for each instance + // now that all the pod annotations have been collected. This final value will be + // checked to ensure that the value from the annotations can be parsed to a valid + // value. Otherwise the previous value, if available, will be used. if autogrow { for _, instance := range observed.bySet[name] { - status.DesiredPGDataVolume[instance.Name] = r.storeDesiredRequest(ctx, cluster, + status.DesiredPGDataVolume[instance.Name] = r.storeDesiredRequest(ctx, cluster, "pgData", name, status.DesiredPGDataVolume[instance.Name], previousDesiredRequests[instance.Name]) } } @@ -371,67 +373,6 @@ func (r *Reconciler) observeInstances( return observed, err } -// storeDesiredRequest saves the appropriate request value to the PostgresCluster -// status. If the value has grown, create an Event. -func (r *Reconciler) storeDesiredRequest( - ctx context.Context, cluster *v1beta1.PostgresCluster, - instanceSetName, desiredRequest, desiredRequestBackup string, -) string { - var current resource.Quantity - var previous resource.Quantity - var err error - log := logging.FromContext(ctx) - - // Parse the desired request from the cluster's status. - if desiredRequest != "" { - current, err = resource.ParseQuantity(desiredRequest) - if err != nil { - log.Error(err, "Unable to parse pgData volume request from status ("+ - desiredRequest+") for "+cluster.Name+"/"+instanceSetName) - // If there was an error parsing the value, treat as unset (equivalent to zero). - desiredRequest = "" - current, _ = resource.ParseQuantity("") - - } - } - - // Parse the desired request from the status backup. - if desiredRequestBackup != "" { - previous, err = resource.ParseQuantity(desiredRequestBackup) - if err != nil { - log.Error(err, "Unable to parse pgData volume request from status backup ("+ - desiredRequestBackup+") for "+cluster.Name+"/"+instanceSetName) - // If there was an error parsing the value, treat as unset (equivalent to zero). - desiredRequestBackup = "" - previous, _ = resource.ParseQuantity("") - - } - } - - // Determine if the limit is set for this instance set. - var limitSet bool - for _, specInstance := range cluster.Spec.InstanceSets { - if specInstance.Name == instanceSetName { - limitSet = !specInstance.DataVolumeClaimSpec.Resources.Limits.Storage().IsZero() - } - } - - if limitSet && current.Value() > previous.Value() { - r.Recorder.Eventf(cluster, corev1.EventTypeNormal, "VolumeAutoGrow", - "pgData volume expansion to %v requested for %s/%s.", - current.String(), cluster.Name, instanceSetName) - } - - // If the desired size was not observed, update with previously stored value. - // This can happen in scenarios where the annotation on the Pod is missing - // such as when the cluster is shutdown or a Pod is in the middle of a restart. - if desiredRequest == "" { - desiredRequest = desiredRequestBackup - } - - return desiredRequest -} - // +kubebuilder:rbac:groups="",resources="pods",verbs={list} // +kubebuilder:rbac:groups="apps",resources="statefulsets",verbs={patch} @@ -466,7 +407,7 @@ func (r *Reconciler) deleteInstances( // stop schedules pod for deletion by scaling its controller to zero. stop := func(pod *corev1.Pod) error { - instance := &unstructured.Unstructured{} + instance := &appsv1.StatefulSet{} instance.SetNamespace(cluster.Namespace) switch owner := metav1.GetControllerOfNoCopy(pod); { @@ -474,8 +415,6 @@ func (r *Reconciler) deleteInstances( return errors.Errorf("pod %q has no owner", client.ObjectKeyFromObject(pod)) case owner.Kind == "StatefulSet": - instance.SetAPIVersion(owner.APIVersion) - instance.SetKind(owner.Kind) instance.SetName(owner.Name) default: @@ -536,7 +475,7 @@ func (r *Reconciler) deleteInstance( cluster *v1beta1.PostgresCluster, instanceName string, ) error { - gvks := []schema.GroupVersionKind{{ + gvks := []runtime.GVK{{ Group: corev1.SchemeGroupVersion.Group, Version: corev1.SchemeGroupVersion.Version, Kind: "ConfigMapList", @@ -591,9 +530,10 @@ func (r *Reconciler) reconcileInstanceSets( instances *observedInstances, patroniLeaderService *corev1.Service, primaryCertificate *corev1.SecretProjection, - clusterVolumes []corev1.PersistentVolumeClaim, + clusterVolumes []*corev1.PersistentVolumeClaim, exporterQueriesConfig, exporterWebConfig *corev1.ConfigMap, backupsSpecFound bool, + otelConfig *collector.Config, ) error { // Go through the observed instances and check if a primary has been determined. @@ -631,7 +571,7 @@ func (r *Reconciler) reconcileInstanceSets( patroniLeaderService, primaryCertificate, findAvailableInstanceNames(*set, instances, clusterVolumes), numInstancePods, clusterVolumes, exporterQueriesConfig, exporterWebConfig, - backupsSpecFound, + backupsSpecFound, otelConfig, ) if err == nil { @@ -709,12 +649,12 @@ func (r *Reconciler) cleanupPodDisruptionBudgets( // for the instance set specified that are not currently associated with an instance, and then // returning the instance names associated with those PVC's. func findAvailableInstanceNames(set v1beta1.PostgresInstanceSetSpec, - observedInstances *observedInstances, clusterVolumes []corev1.PersistentVolumeClaim) []string { + observedInstances *observedInstances, clusterVolumes []*corev1.PersistentVolumeClaim) []string { availableInstanceNames := []string{} // first identify any PGDATA volumes for the instance set specified - setVolumes := []corev1.PersistentVolumeClaim{} + setVolumes := []*corev1.PersistentVolumeClaim{} for _, pvc := range clusterVolumes { // ignore PGDATA PVCs that are terminating if pvc.GetDeletionTimestamp() != nil { @@ -732,7 +672,7 @@ func findAvailableInstanceNames(set v1beta1.PostgresInstanceSetSpec, // any available PGDATA volumes for the instance set that have no corresponding WAL // volumes (which means new PVCs will simply be reconciled instead). if set.WALVolumeClaimSpec != nil { - setVolumesWithWAL := []corev1.PersistentVolumeClaim{} + setVolumesWithWAL := []*corev1.PersistentVolumeClaim{} for _, setVol := range setVolumes { setVolInstance := setVol.GetLabels()[naming.LabelInstance] for _, pvc := range clusterVolumes { @@ -753,7 +693,7 @@ func findAvailableInstanceNames(set v1beta1.PostgresInstanceSetSpec, } // Determine whether or not the PVC is associated with an existing instance within the same - // instance set. If not, then the instance name associated with that PVC can be be reused. + // instance set. If not, then the instance name associated with that PVC can be reused. for _, pvc := range setVolumes { pvcInstanceName := pvc.GetLabels()[naming.LabelInstance] instance := observedInstances.byName[pvcInstanceName] @@ -803,8 +743,7 @@ func (r *Reconciler) rolloutInstance( // NOTE(cbandy): The StatefulSet controlling this Pod reflects this change // in its Status and triggers another reconcile. if primary && len(instances.forCluster) > 1 { - var span trace.Span - ctx, span = r.Tracer.Start(ctx, "patroni-change-primary") + ctx, span := tracing.Start(ctx, "patroni-change-primary") defer span.End() success, err := patroni.Executor(exec).ChangePrimaryAndWait(ctx, pod.Name, "") @@ -812,8 +751,7 @@ func (r *Reconciler) rolloutInstance( err = errors.New("unable to switchover") } - span.RecordError(err) - return err + return tracing.Escape(span, err) } // When the cluster has only one instance for failover, perform a series of @@ -827,7 +765,7 @@ func (r *Reconciler) rolloutInstance( } checkpoint := func(ctx context.Context) (time.Duration, error) { - ctx, span := r.Tracer.Start(ctx, "postgresql-checkpoint") + ctx, span := tracing.Start(ctx, "postgresql-checkpoint") defer span.End() start := time.Now() @@ -845,8 +783,7 @@ func (r *Reconciler) rolloutInstance( logging.FromContext(ctx).V(1).Info("attempted checkpoint", "duration", elapsed, "stdout", stdout, "stderr", stderr) - span.RecordError(err) - return elapsed, err + return elapsed, tracing.Escape(span, err) } duration, err := checkpoint(ctx) @@ -897,7 +834,7 @@ func (r *Reconciler) rolloutInstances( var numAvailable int var numSpecified int - ctx, span := r.Tracer.Start(ctx, "rollout-instances") + ctx, span := tracing.Start(ctx, "rollout-instances") defer span.End() for _, set := range cluster.Spec.InstanceSets { @@ -936,12 +873,10 @@ func (r *Reconciler) rolloutInstances( sort.Sort(byPriority(consider)) } - span.SetAttributes( - attribute.Int("instances", len(instances.forCluster)), - attribute.Int("specified", numSpecified), - attribute.Int("available", numAvailable), - attribute.Int("considering", len(consider)), - ) + tracing.Int(span, "instances", len(instances.forCluster)) + tracing.Int(span, "specified", numSpecified) + tracing.Int(span, "available", numAvailable) + tracing.Int(span, "considering", len(consider)) // Redeploy instances up to the allowed maximum while "rolling over" any // unavailable instances. @@ -957,8 +892,7 @@ func (r *Reconciler) rolloutInstances( } } - span.RecordError(err) - return err + return tracing.Escape(span, err) } // scaleDownInstances removes extra instances from a cluster until it matches @@ -1069,9 +1003,10 @@ func (r *Reconciler) scaleUpInstances( primaryCertificate *corev1.SecretProjection, availableInstanceNames []string, numInstancePods int, - clusterVolumes []corev1.PersistentVolumeClaim, + clusterVolumes []*corev1.PersistentVolumeClaim, exporterQueriesConfig, exporterWebConfig *corev1.ConfigMap, backupsSpecFound bool, + otelConfig *collector.Config, ) ([]*appsv1.StatefulSet, error) { log := logging.FromContext(ctx) @@ -1088,21 +1023,23 @@ func (r *Reconciler) scaleUpInstances( // While there are fewer instances than specified, generate another empty one // and append it. for len(instances) < int(*set.Replicas) { - var span trace.Span - ctx, span = r.Tracer.Start(ctx, "generateInstanceName") - next := naming.GenerateInstance(cluster, set) - // if there are any available instance names (as determined by observing any PVCs for the - // instance set that are not currently associated with an instance, e.g. in the event the - // instance STS was deleted), then reuse them instead of generating a new name - if len(availableInstanceNames) > 0 { - next.Name = availableInstanceNames[0] - availableInstanceNames = availableInstanceNames[1:] - } else { - for instanceNames.Has(next.Name) { - next = naming.GenerateInstance(cluster, set) + next := func() metav1.ObjectMeta { + _, span := tracing.Start(ctx, "generate-instance-name") + defer span.End() + n := naming.GenerateInstance(cluster, set) + // if there are any available instance names (as determined by observing any PVCs for the + // instance set that are not currently associated with an instance, e.g. in the event the + // instance STS was deleted), then reuse them instead of generating a new name + if len(availableInstanceNames) > 0 { + n.Name = availableInstanceNames[0] + availableInstanceNames = availableInstanceNames[1:] + } else { + for instanceNames.Has(n.Name) { + n = naming.GenerateInstance(cluster, set) + } } - } - span.End() + return n + }() instanceNames.Insert(next.Name) instances = append(instances, &appsv1.StatefulSet{ObjectMeta: next}) @@ -1116,7 +1053,7 @@ func (r *Reconciler) scaleUpInstances( rootCA, clusterPodService, instanceServiceAccount, patroniLeaderService, primaryCertificate, instances[i], numInstancePods, clusterVolumes, exporterQueriesConfig, exporterWebConfig, - backupsSpecFound, + backupsSpecFound, otelConfig, ) } if err == nil { @@ -1144,9 +1081,10 @@ func (r *Reconciler) reconcileInstance( primaryCertificate *corev1.SecretProjection, instance *appsv1.StatefulSet, numInstancePods int, - clusterVolumes []corev1.PersistentVolumeClaim, + clusterVolumes []*corev1.PersistentVolumeClaim, exporterQueriesConfig, exporterWebConfig *corev1.ConfigMap, backupsSpecFound bool, + otelConfig *collector.Config, ) error { log := logging.FromContext(ctx).WithValues("instance", instance.Name) ctx = logging.NewContext(ctx, log) @@ -1171,11 +1109,11 @@ func (r *Reconciler) reconcileInstance( ) if err == nil { - instanceConfigMap, err = r.reconcileInstanceConfigMap(ctx, cluster, spec, instance) + instanceConfigMap, err = r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, backupsSpecFound) } if err == nil { instanceCertificates, err = r.reconcileInstanceCertificates( - ctx, cluster, spec, instance, rootCA) + ctx, cluster, spec, instance, rootCA, backupsSpecFound) } if err == nil { postgresDataVolume, err = r.reconcilePostgresDataVolume(ctx, cluster, spec, instance, clusterVolumes, nil) @@ -1191,7 +1129,7 @@ func (r *Reconciler) reconcileInstance( ctx, cluster, spec, primaryCertificate, replicationCertSecretProjection(clusterReplicationSecret), postgresDataVolume, postgresWALVolume, tablespaceVolumes, - &instance.Spec.Template.Spec) + &instance.Spec.Template) if backupsSpecFound { addPGBackRestToInstancePodSpec( @@ -1203,9 +1141,37 @@ func (r *Reconciler) reconcileInstance( spec, instanceCertificates, instanceConfigMap, &instance.Spec.Template) } - // Add pgMonitor resources to the instance Pod spec + // If either OpenTelemetry feature is enabled, we want to add the collector config to the pod + if err == nil && + collector.OpenTelemetryLogsOrMetricsEnabled(ctx, cluster) { + + // If the OpenTelemetryMetrics feature is enabled, we need to get the pgpassword from the + // monitoring user secret + pgPassword := "" + if feature.Enabled(ctx, feature.OpenTelemetryMetrics) { + monitoringUserSecret := &corev1.Secret{ObjectMeta: naming.MonitoringUserSecret(cluster)} + // Create new err variable to avoid abandoning the rest of the reconcile loop if there + // is an error getting the monitoring user secret + err := errors.WithStack( + r.Client.Get(ctx, client.ObjectKeyFromObject(monitoringUserSecret), monitoringUserSecret)) + if err == nil { + pgPassword = string(monitoringUserSecret.Data["password"]) + } + } + + // For now, we are not using logrotate to rotate postgres or patroni logs, + // but we are using it for pgbackrest logs in the postgres pod, so we will + // set includeLogrotate to true, but only if backups are enabled + // and local volumes are available. + includeLogrotate := backupsSpecFound && pgbackrest.RepoHostVolumeDefined(cluster) + collector.AddToPod(ctx, cluster.Spec.Instrumentation, cluster.Spec.ImagePullPolicy, instanceConfigMap, &instance.Spec.Template, + []corev1.VolumeMount{postgres.DataVolumeMount()}, pgPassword, + []string{naming.PGBackRestPGDataLogPath}, includeLogrotate, true) + } + + // Add postgres-exporter to the instance Pod spec if err == nil { - err = addPGMonitorToInstancePodSpec(ctx, cluster, &instance.Spec.Template, exporterQueriesConfig, exporterWebConfig) + addPGMonitorExporterToInstancePodSpec(ctx, cluster, &instance.Spec.Template, exporterQueriesConfig, exporterWebConfig) } // add nss_wrapper init container and add nss_wrapper env vars to the database and pgbackrest @@ -1220,7 +1186,7 @@ func (r *Reconciler) reconcileInstance( // add an emptyDir volume to the PodTemplateSpec and an associated '/tmp' volume mount to // all containers included within that spec if err == nil { - addTMPEmptyDir(&instance.Spec.Template) + AddTMPEmptyDir(&instance.Spec.Template) } // mount shared memory to the Postgres instance @@ -1228,6 +1194,16 @@ func (r *Reconciler) reconcileInstance( addDevSHM(&instance.Spec.Template) } + // mount additional volumes to the Postgres instance containers + if err == nil && spec.Volumes != nil && len(spec.Volumes.Additional) > 0 { + missingContainers := addAdditionalVolumesToSpecifiedContainers(&instance.Spec.Template, spec.Volumes.Additional) + + if len(missingContainers) > 0 { + r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "SpecifiedContainerNotFound", + "The following Postgres pod containers were specified for additional volumes but cannot be found: %s.", missingContainers) + } + } + if err == nil { err = errors.WithStack(r.apply(ctx, instance)) } @@ -1268,6 +1244,9 @@ func generateInstanceStatefulSetIntent(_ context.Context, sts.Spec.Template.Annotations = naming.Merge( cluster.Spec.Metadata.GetAnnotationsOrNil(), spec.Metadata.GetAnnotationsOrNil(), + map[string]string{ + naming.DefaultContainerAnnotation: naming.ContainerDatabase, + }, ) sts.Spec.Template.Labels = naming.Merge( cluster.Spec.Metadata.GetLabelsOrNil(), @@ -1381,7 +1360,7 @@ func addPGBackRestToInstancePodSpec( // files (etc) that apply to instance of cluster. func (r *Reconciler) reconcileInstanceConfigMap( ctx context.Context, cluster *v1beta1.PostgresCluster, spec *v1beta1.PostgresInstanceSetSpec, - instance *appsv1.StatefulSet, + instance *appsv1.StatefulSet, otelConfig *collector.Config, backupsSpecFound bool, ) (*corev1.ConfigMap, error) { instanceConfigMap := &corev1.ConfigMap{ObjectMeta: naming.InstanceConfigMap(instance)} instanceConfigMap.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) @@ -1401,6 +1380,24 @@ func (r *Reconciler) reconcileInstanceConfigMap( naming.LabelInstance: instance.Name, }) + // If OTel logging or metrics is enabled, add collector config + if err == nil && + collector.OpenTelemetryLogsOrMetricsEnabled(ctx, cluster) { + + err = collector.AddToConfigMap(ctx, otelConfig, instanceConfigMap) + + // Add pgbackrest logrotate if OpenTelemetryLogs is enabled and + // backups are enabled + if err == nil && + collector.OpenTelemetryLogsEnabled(ctx, cluster) && backupsSpecFound { + + collector.AddLogrotateConfigs(ctx, cluster.Spec.Instrumentation, + instanceConfigMap, + []collector.LogrotateConfig{{ + LogFiles: []string{naming.PGBackRestPGDataLogPath + "/*.log"}, + }}) + } + } if err == nil { err = patroni.InstanceConfigMap(ctx, cluster, spec, instanceConfigMap) } @@ -1419,7 +1416,7 @@ func (r *Reconciler) reconcileInstanceConfigMap( func (r *Reconciler) reconcileInstanceCertificates( ctx context.Context, cluster *v1beta1.PostgresCluster, spec *v1beta1.PostgresInstanceSetSpec, instance *appsv1.StatefulSet, - root *pki.RootCertificateAuthority, + root *pki.RootCertificateAuthority, backupsSpecFound bool, ) (*corev1.Secret, error) { existing := &corev1.Secret{ObjectMeta: naming.InstanceCertificates(instance)} err := errors.WithStack(client.IgnoreNotFound( @@ -1462,7 +1459,7 @@ func (r *Reconciler) reconcileInstanceCertificates( root.Certificate, leafCert.Certificate, leafCert.PrivateKey, instanceCerts) } - if err == nil { + if err == nil && backupsSpecFound { err = pgbackrest.InstanceCertificates(ctx, cluster, root.Certificate, leafCert.Certificate, leafCert.PrivateKey, instanceCerts) diff --git a/internal/controller/postgrescluster/instance.md b/internal/controller/postgrescluster/instance.md index f0de4c5d7a..724219ae22 100644 --- a/internal/controller/postgrescluster/instance.md +++ b/internal/controller/postgrescluster/instance.md @@ -1,5 +1,5 @@ diff --git a/internal/controller/postgrescluster/instance_rollout_test.go b/internal/controller/postgrescluster/instance_rollout_test.go index e668907497..7bd63ce9d1 100644 --- a/internal/controller/postgrescluster/instance_rollout_test.go +++ b/internal/controller/postgrescluster/instance_rollout_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -10,7 +10,6 @@ import ( "strings" "testing" - "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" @@ -25,6 +24,7 @@ import ( "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/tracing" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -60,7 +60,6 @@ func TestReconcilerRolloutInstance(t *testing.T) { key := client.ObjectKey{Namespace: "ns1", Name: "one-pod-bruh"} reconciler := &Reconciler{} reconciler.Client = fake.NewClientBuilder().WithObjects(instances[0].Pods[0]).Build() - reconciler.Tracer = otel.Tracer(t.Name()) execCalls := 0 reconciler.PodExec = func( @@ -121,7 +120,6 @@ func TestReconcilerRolloutInstance(t *testing.T) { t.Run("Success", func(t *testing.T) { execCalls := 0 reconciler := &Reconciler{} - reconciler.Tracer = otel.Tracer(t.Name()) reconciler.PodExec = func( ctx context.Context, namespace, pod, container string, _ io.Reader, stdout, _ io.Writer, command ...string, ) error { @@ -134,7 +132,7 @@ func TestReconcilerRolloutInstance(t *testing.T) { // A switchover to any viable candidate. assert.DeepEqual(t, command[:2], []string{"patronictl", "switchover"}) - assert.Assert(t, sets.NewString(command...).Has("--master=the-pod")) + assert.Assert(t, sets.NewString(command...).Has("--primary=the-pod")) assert.Assert(t, sets.NewString(command...).Has("--candidate=")) // Indicate success through stdout. @@ -149,7 +147,6 @@ func TestReconcilerRolloutInstance(t *testing.T) { t.Run("Failure", func(t *testing.T) { reconciler := &Reconciler{} - reconciler.Tracer = otel.Tracer(t.Name()) reconciler.PodExec = func( ctx context.Context, _, _, _ string, _ io.Reader, _, _ io.Writer, _ ...string, ) error { @@ -165,26 +162,25 @@ func TestReconcilerRolloutInstance(t *testing.T) { func TestReconcilerRolloutInstances(t *testing.T) { ctx := context.Background() - reconciler := &Reconciler{Tracer: otel.Tracer(t.Name())} + reconciler := &Reconciler{} accumulate := func(on *[]*Instance) func(context.Context, *Instance) error { return func(_ context.Context, i *Instance) error { *on = append(*on, i); return nil } } - logSpanAttributes := func(t testing.TB) { + logSpanAttributes := func(t testing.TB, ctx context.Context) context.Context { recorder := tracetest.NewSpanRecorder() provider := trace.NewTracerProvider(trace.WithSpanProcessor(recorder)) - - former := reconciler.Tracer - reconciler.Tracer = provider.Tracer(t.Name()) + tracer := provider.Tracer(t.Name()) t.Cleanup(func() { - reconciler.Tracer = former for _, span := range recorder.Ended() { attr := attribute.NewSet(span.Attributes()...) t.Log(span.Name(), attr.Encoded(attribute.DefaultEncoder())) } }) + + return tracing.NewContext(ctx, tracer) } // Nothing specified, nothing observed, nothing to do. @@ -192,7 +188,7 @@ func TestReconcilerRolloutInstances(t *testing.T) { cluster := new(v1beta1.PostgresCluster) observed := new(observedInstances) - logSpanAttributes(t) + ctx := logSpanAttributes(t, ctx) assert.NilError(t, reconciler.rolloutInstances(ctx, cluster, observed, func(context.Context, *Instance) error { t.Fatal("expected no redeploys") @@ -237,7 +233,7 @@ func TestReconcilerRolloutInstances(t *testing.T) { } observed := &observedInstances{forCluster: instances} - logSpanAttributes(t) + ctx := logSpanAttributes(t, ctx) assert.NilError(t, reconciler.rolloutInstances(ctx, cluster, observed, func(context.Context, *Instance) error { t.Fatal("expected no redeploys") @@ -284,7 +280,7 @@ func TestReconcilerRolloutInstances(t *testing.T) { var redeploys []*Instance - logSpanAttributes(t) + ctx := logSpanAttributes(t, ctx) assert.NilError(t, reconciler.rolloutInstances(ctx, cluster, observed, accumulate(&redeploys))) assert.Equal(t, len(redeploys), 1) assert.Equal(t, redeploys[0].Name, "one") @@ -354,7 +350,7 @@ func TestReconcilerRolloutInstances(t *testing.T) { var redeploys []*Instance - logSpanAttributes(t) + ctx := logSpanAttributes(t, ctx) assert.NilError(t, reconciler.rolloutInstances(ctx, cluster, observed, accumulate(&redeploys))) assert.Equal(t, len(redeploys), 1) assert.Equal(t, redeploys[0].Name, "one", `expected the "lowest" name`) @@ -425,7 +421,7 @@ func TestReconcilerRolloutInstances(t *testing.T) { var redeploys []*Instance - logSpanAttributes(t) + ctx := logSpanAttributes(t, ctx) assert.NilError(t, reconciler.rolloutInstances(ctx, cluster, observed, accumulate(&redeploys))) assert.Equal(t, len(redeploys), 1) assert.Equal(t, redeploys[0].Name, "not-primary") @@ -495,7 +491,7 @@ func TestReconcilerRolloutInstances(t *testing.T) { var redeploys []*Instance - logSpanAttributes(t) + ctx := logSpanAttributes(t, ctx) assert.NilError(t, reconciler.rolloutInstances(ctx, cluster, observed, accumulate(&redeploys))) assert.Equal(t, len(redeploys), 1) assert.Equal(t, redeploys[0].Name, "not-ready") @@ -564,7 +560,7 @@ func TestReconcilerRolloutInstances(t *testing.T) { } observed := &observedInstances{forCluster: instances} - logSpanAttributes(t) + ctx := logSpanAttributes(t, ctx) assert.NilError(t, reconciler.rolloutInstances(ctx, cluster, observed, func(context.Context, *Instance) error { t.Fatal("expected no redeploys") @@ -633,7 +629,7 @@ func TestReconcilerRolloutInstances(t *testing.T) { } observed := &observedInstances{forCluster: instances} - logSpanAttributes(t) + ctx := logSpanAttributes(t, ctx) assert.NilError(t, reconciler.rolloutInstances(ctx, cluster, observed, func(context.Context, *Instance) error { t.Fatal("expected no redeploys") diff --git a/internal/controller/postgrescluster/instance_test.go b/internal/controller/postgrescluster/instance_test.go index f7f59f50a5..bc4402183e 100644 --- a/internal/controller/postgrescluster/instance_test.go +++ b/internal/controller/postgrescluster/instance_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,6 +6,7 @@ package postgrescluster import ( "context" + "errors" "fmt" "os" "sort" @@ -13,10 +14,7 @@ import ( "testing" "time" - "github.com/go-logr/logr/funcr" "github.com/google/go-cmp/cmp/cmpopts" - "github.com/pkg/errors" - "go.opentelemetry.io/otel" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -25,7 +23,6 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/sets" @@ -34,12 +31,12 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/controller/runtime" + "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" - "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/testing/cmp" - "github.com/crunchydata/postgres-operator/internal/testing/events" "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -119,7 +116,7 @@ func TestInstanceIsWritable(t *testing.T) { assert.Assert(t, !writable) // Patroni leader - instance.Pods[0].Annotations["status"] = `{"role":"master"}` + instance.Pods[0].Annotations["status"] = `{"role":"primary"}` writable, known = instance.IsWritable() assert.Assert(t, known) assert.Assert(t, writable) @@ -260,121 +257,6 @@ func TestNewObservedInstances(t *testing.T) { }) } -func TestStoreDesiredRequest(t *testing.T) { - ctx := context.Background() - - setupLogCapture := func(ctx context.Context) (context.Context, *[]string) { - calls := []string{} - testlog := funcr.NewJSON(func(object string) { - calls = append(calls, object) - }, funcr.Options{ - Verbosity: 1, - }) - return logging.NewContext(ctx, testlog), &calls - } - - cluster := v1beta1.PostgresCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "rhino", - Namespace: "test-namespace", - }, - Spec: v1beta1.PostgresClusterSpec{ - InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ - Name: "red", - Replicas: initialize.Int32(1), - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Limits: map[corev1.ResourceName]resource.Quantity{ - corev1.ResourceStorage: resource.MustParse("1Gi"), - }}}, - }, { - Name: "blue", - Replicas: initialize.Int32(1), - }}}} - - t.Run("BadRequestNoBackup", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - value := reconciler.storeDesiredRequest(ctx, &cluster, "red", "woot", "") - - assert.Equal(t, value, "") - assert.Equal(t, len(recorder.Events), 0) - assert.Equal(t, len(*logs), 1) - assert.Assert(t, cmp.Contains((*logs)[0], "Unable to parse pgData volume request from status")) - }) - - t.Run("BadRequestWithBackup", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - value := reconciler.storeDesiredRequest(ctx, &cluster, "red", "foo", "1Gi") - - assert.Equal(t, value, "1Gi") - assert.Equal(t, len(recorder.Events), 0) - assert.Equal(t, len(*logs), 1) - assert.Assert(t, cmp.Contains((*logs)[0], "Unable to parse pgData volume request from status (foo) for rhino/red")) - }) - - t.Run("NoLimitNoEvent", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - value := reconciler.storeDesiredRequest(ctx, &cluster, "blue", "1Gi", "") - - assert.Equal(t, value, "1Gi") - assert.Equal(t, len(*logs), 0) - assert.Equal(t, len(recorder.Events), 0) - }) - - t.Run("BadBackupRequest", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - value := reconciler.storeDesiredRequest(ctx, &cluster, "red", "2Gi", "bar") - - assert.Equal(t, value, "2Gi") - assert.Equal(t, len(*logs), 1) - assert.Assert(t, cmp.Contains((*logs)[0], "Unable to parse pgData volume request from status backup (bar) for rhino/red")) - assert.Equal(t, len(recorder.Events), 1) - assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) - assert.Equal(t, recorder.Events[0].Reason, "VolumeAutoGrow") - assert.Equal(t, recorder.Events[0].Note, "pgData volume expansion to 2Gi requested for rhino/red.") - }) - - t.Run("ValueUpdateWithEvent", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - value := reconciler.storeDesiredRequest(ctx, &cluster, "red", "1Gi", "") - - assert.Equal(t, value, "1Gi") - assert.Equal(t, len(*logs), 0) - assert.Equal(t, len(recorder.Events), 1) - assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) - assert.Equal(t, recorder.Events[0].Reason, "VolumeAutoGrow") - assert.Equal(t, recorder.Events[0].Note, "pgData volume expansion to 1Gi requested for rhino/red.") - }) - - t.Run("NoLimitNoEvent", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - value := reconciler.storeDesiredRequest(ctx, &cluster, "blue", "1Gi", "") - - assert.Equal(t, value, "1Gi") - assert.Equal(t, len(*logs), 0) - assert.Equal(t, len(recorder.Events), 0) - }) -} - func TestWritablePod(t *testing.T) { container := "container" @@ -394,7 +276,7 @@ func TestWritablePod(t *testing.T) { Namespace: "namespace", Name: "pod", Annotations: map[string]string{ - "status": `{"role":"master"}`, + "status": `{"role":"primary"}`, }, DeletionTimestamp: &metav1.Time{}, }, @@ -428,7 +310,7 @@ func TestWritablePod(t *testing.T) { Namespace: "namespace", Name: "pod", Annotations: map[string]string{ - "status": `{"role":"master"}`, + "status": `{"role":"primary"}`, }, }, Status: corev1.PodStatus{ @@ -493,7 +375,7 @@ func TestWritablePod(t *testing.T) { Namespace: "namespace", Name: "pod", Annotations: map[string]string{ - "status": `{"role":"master"}`, + "status": `{"role":"primary"}`, }, }, Status: corev1.PodStatus{ @@ -546,151 +428,7 @@ func TestAddPGBackRestToInstancePodSpec(t *testing.T) { }, } - t.Run("NoVolumeRepo", func(t *testing.T) { - cluster := cluster.DeepCopy() - cluster.Spec.Backups.PGBackRest.Repos = nil - - out := pod.DeepCopy() - addPGBackRestToInstancePodSpec(ctx, cluster, &certificates, out) - - // Only Containers and Volumes fields have changed. - assert.DeepEqual(t, pod, *out, cmpopts.IgnoreFields(pod, "Containers", "Volumes")) - - // Only database container has mounts. - // Other containers are ignored. - assert.Assert(t, cmp.MarshalMatches(out.Containers, ` -- name: database - resources: {} - volumeMounts: - - mountPath: /etc/pgbackrest/conf.d - name: pgbackrest-config - readOnly: true -- name: other - resources: {} -- command: - - pgbackrest - - server - livenessProbe: - exec: - command: - - pgbackrest - - server-ping - name: pgbackrest - resources: {} - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - privileged: false - readOnlyRootFilesystem: true - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - volumeMounts: - - mountPath: /etc/pgbackrest/server - name: pgbackrest-server - readOnly: true - - mountPath: /pgdata - name: postgres-data - - mountPath: /pgwal - name: postgres-wal - - mountPath: /etc/pgbackrest/conf.d - name: pgbackrest-config - readOnly: true -- command: - - bash - - -ceu - - -- - - |- - monitor() { - exec {fd}<> <(:||:) - until read -r -t 5 -u "${fd}"; do - if - [[ "${filename}" -nt "/proc/self/fd/${fd}" ]] && - pkill -HUP --exact --parent=0 pgbackrest - then - exec {fd}>&- && exec {fd}<> <(:||:) - stat --dereference --format='Loaded configuration dated %y' "${filename}" - elif - { [[ "${directory}" -nt "/proc/self/fd/${fd}" ]] || - [[ "${authority}" -nt "/proc/self/fd/${fd}" ]] - } && - pkill -HUP --exact --parent=0 pgbackrest - then - exec {fd}>&- && exec {fd}<> <(:||:) - stat --format='Loaded certificates dated %y' "${directory}" - fi - done - }; export directory="$1" authority="$2" filename="$3"; export -f monitor; exec -a "$0" bash -ceu monitor - - pgbackrest-config - - /etc/pgbackrest/server - - /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt - - /etc/pgbackrest/conf.d/~postgres-operator_server.conf - name: pgbackrest-config - resources: {} - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - privileged: false - readOnlyRootFilesystem: true - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - volumeMounts: - - mountPath: /etc/pgbackrest/server - name: pgbackrest-server - readOnly: true - - mountPath: /etc/pgbackrest/conf.d - name: pgbackrest-config - readOnly: true - `)) - - // Instance configuration files with certificates. - // Other volumes are ignored. - assert.Assert(t, cmp.MarshalMatches(out.Volumes, ` -- name: other -- name: postgres-data -- name: postgres-wal -- name: pgbackrest-server - projected: - sources: - - secret: - items: - - key: pgbackrest-server.crt - path: server-tls.crt - - key: pgbackrest-server.key - mode: 384 - path: server-tls.key - name: some-secret -- name: pgbackrest-config - projected: - sources: - - configMap: - items: - - key: pgbackrest_instance.conf - path: pgbackrest_instance.conf - - key: config-hash - path: config-hash - - key: pgbackrest-server.conf - path: ~postgres-operator_server.conf - name: hippo-pgbackrest-config - - secret: - items: - - key: pgbackrest.ca-roots - path: ~postgres-operator/tls-ca.crt - - key: pgbackrest-client.crt - path: ~postgres-operator/client-tls.crt - - key: pgbackrest-client.key - mode: 384 - path: ~postgres-operator/client-tls.key - name: hippo-pgbackrest - `)) - }) - - t.Run("OneVolumeRepo", func(t *testing.T) { + t.Run("CloudOrVolumeSameBehavior", func(t *testing.T) { alwaysExpect := func(t testing.TB, result *corev1.PodSpec) { // Only Containers and Volumes fields have changed. assert.DeepEqual(t, pod, *result, cmpopts.IgnoreFields(pod, "Containers", "Volumes")) @@ -735,24 +473,35 @@ func TestAddPGBackRestToInstancePodSpec(t *testing.T) { mode: 384 path: ~postgres-operator/client-tls.key name: hippo-pgbackrest + optional: true `)) } - cluster := cluster.DeepCopy() - cluster.Spec.Backups.PGBackRest.Repos = []v1beta1.PGBackRestRepo{ + clusterWithVolume := cluster.DeepCopy() + clusterWithVolume.Spec.Backups.PGBackRest.Repos = []v1beta1.PGBackRestRepo{ { Name: "repo1", Volume: new(v1beta1.RepoPVC), }, } - out := pod.DeepCopy() - addPGBackRestToInstancePodSpec(ctx, cluster, &certificates, out) - alwaysExpect(t, out) + clusterWithCloudRepo := cluster.DeepCopy() + clusterWithCloudRepo.Spec.Backups.PGBackRest.Repos = []v1beta1.PGBackRestRepo{ + { + Name: "repo1", + GCS: new(v1beta1.RepoGCS), + }, + } - // The TLS server is added and configuration mounted. - // It has PostgreSQL volumes mounted while other volumes are ignored. - assert.Assert(t, cmp.MarshalMatches(out.Containers, ` + outWithVolume := pod.DeepCopy() + addPGBackRestToInstancePodSpec(ctx, clusterWithVolume, &certificates, outWithVolume) + alwaysExpect(t, outWithVolume) + + outWithCloudRepo := pod.DeepCopy() + addPGBackRestToInstancePodSpec(ctx, clusterWithCloudRepo, &certificates, outWithCloudRepo) + alwaysExpect(t, outWithCloudRepo) + + outContainers := ` - name: database resources: {} volumeMounts: @@ -840,7 +589,12 @@ func TestAddPGBackRestToInstancePodSpec(t *testing.T) { - mountPath: /etc/pgbackrest/conf.d name: pgbackrest-config readOnly: true - `)) + ` + + // The TLS server is added and configuration mounted. + // It has PostgreSQL volumes mounted while other volumes are ignored. + assert.Assert(t, cmp.MarshalMatches(outWithVolume.Containers, outContainers)) + assert.Assert(t, cmp.MarshalMatches(outWithCloudRepo.Containers, outContainers)) t.Run("CustomResources", func(t *testing.T) { cluster := cluster.DeepCopy() @@ -857,7 +611,7 @@ func TestAddPGBackRestToInstancePodSpec(t *testing.T) { }, } - before := out.DeepCopy() + before := outWithVolume.DeepCopy() out := pod.DeepCopy() addPGBackRestToInstancePodSpec(ctx, cluster, &certificates, out) alwaysExpect(t, out) @@ -966,7 +720,7 @@ func TestPodsToKeep(t *testing.T) { checks func(*testing.T, []corev1.Pod) }{ { - name: "RemoveSetWithMasterOnly", + name: "RemoveSetWithPrimaryOnly", instances: []corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ @@ -1000,7 +754,7 @@ func TestPodsToKeep(t *testing.T) { assert.Equal(t, len(p), 0) }, }, { - name: "KeepMasterOnly", + name: "KeepPrimaryOnly", instances: []corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ @@ -1089,7 +843,7 @@ func TestPodsToKeep(t *testing.T) { assert.Equal(t, len(p), 0) }, }, { - name: "MasterLastInSet", + name: "PrimaryLastInSet", instances: []corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ @@ -1118,7 +872,7 @@ func TestPodsToKeep(t *testing.T) { assert.Equal(t, p[0].Labels[naming.LabelRole], "master") }, }, { - name: "ScaleDownSetWithMaster", + name: "ScaleDownSetWithPrimary", instances: []corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ @@ -1169,7 +923,7 @@ func TestPodsToKeep(t *testing.T) { assert.Equal(t, p[1].Labels[naming.LabelInstanceSet], "max") }, }, { - name: "ScaleDownSetWithoutMaster", + name: "ScaleDownSetWithoutPrimary", instances: []corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ @@ -1222,7 +976,7 @@ func TestPodsToKeep(t *testing.T) { assert.Equal(t, p[2].Labels[naming.LabelRole], "replica") }, }, { - name: "ScaleMasterSetToZero", + name: "ScalePrimarySetToZero", instances: []corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ @@ -1264,7 +1018,7 @@ func TestPodsToKeep(t *testing.T) { assert.Equal(t, p[1].Labels[naming.LabelInstanceSet], "daisy") }, }, { - name: "RemoveMasterInstanceSet", + name: "RemovePrimaryInstanceSet", instances: []corev1.Pod{ { ObjectMeta: metav1.ObjectMeta{ @@ -1340,14 +1094,13 @@ func TestDeleteInstance(t *testing.T) { Client: cc, Owner: client.FieldOwner(t.Name()), Recorder: new(record.FakeRecorder), - Tracer: otel.Tracer(t.Name()), } // Define, Create, and Reconcile a cluster to get an instance running in kube cluster := testCluster() cluster.Namespace = setupNamespace(t, cc).Name - assert.NilError(t, errors.WithStack(reconciler.Client.Create(ctx, cluster))) + assert.NilError(t, reconciler.Client.Create(ctx, cluster)) t.Cleanup(func() { // Remove finalizers, if any, so the namespace can terminate. assert.Check(t, client.IgnoreNotFound( @@ -1377,7 +1130,7 @@ func TestDeleteInstance(t *testing.T) { // Use the instance name to delete the single instance assert.NilError(t, reconciler.deleteInstance(ctx, cluster, instanceName)) - gvks := []schema.GroupVersionKind{ + gvks := []runtime.GVK{ corev1.SchemeGroupVersion.WithKind("PersistentVolumeClaim"), corev1.SchemeGroupVersion.WithKind("ConfigMap"), corev1.SchemeGroupVersion.WithKind("Secret"), @@ -1397,9 +1150,9 @@ func TestDeleteInstance(t *testing.T) { err := wait.PollUntilContextTimeout(ctx, time.Second*3, Scale(time.Second*30), false, func(ctx context.Context) (bool, error) { uList := &unstructured.UnstructuredList{} uList.SetGroupVersionKind(gvk) - assert.NilError(t, errors.WithStack(reconciler.Client.List(ctx, uList, + assert.NilError(t, reconciler.Client.List(ctx, uList, client.InNamespace(cluster.Namespace), - client.MatchingLabelsSelector{Selector: selector}))) + client.MatchingLabelsSelector{Selector: selector})) if len(uList.Items) == 0 { return true, nil @@ -1710,7 +1463,6 @@ func TestGenerateInstanceStatefulSetIntent(t *testing.T) { `)) }, }} { - test := test t.Run(test.name, func(t *testing.T) { cluster := test.ip.cluster @@ -1759,7 +1511,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { testCases := []struct { set v1beta1.PostgresInstanceSetSpec fakeObservedInstances *observedInstances - fakeClusterVolumes []corev1.PersistentVolumeClaim + fakeClusterVolumes []*corev1.PersistentVolumeClaim expectedInstanceNames []string }{{ set: v1beta1.PostgresInstanceSetSpec{Name: "instance1"}, @@ -1770,7 +1522,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { []appsv1.StatefulSet{{}}, []corev1.Pod{}, ), - fakeClusterVolumes: []corev1.PersistentVolumeClaim{{}}, + fakeClusterVolumes: []*corev1.PersistentVolumeClaim{{}}, expectedInstanceNames: []string{}, }, { set: v1beta1.PostgresInstanceSetSpec{Name: "instance1"}, @@ -1784,7 +1536,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { naming.LabelInstanceSet: "instance1"}}}}, []corev1.Pod{}, ), - fakeClusterVolumes: []corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{ + fakeClusterVolumes: []*corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{ Name: "instance1-abc-def", Labels: map[string]string{ naming.LabelRole: naming.RolePostgresData, @@ -1803,7 +1555,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { naming.LabelInstanceSet: "instance1"}}}}, []corev1.Pod{}, ), - fakeClusterVolumes: []corev1.PersistentVolumeClaim{}, + fakeClusterVolumes: []*corev1.PersistentVolumeClaim{}, expectedInstanceNames: []string{}, }, { set: v1beta1.PostgresInstanceSetSpec{Name: "instance1"}, @@ -1817,7 +1569,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { naming.LabelInstanceSet: "instance1"}}}}, []corev1.Pod{}, ), - fakeClusterVolumes: []corev1.PersistentVolumeClaim{ + fakeClusterVolumes: []*corev1.PersistentVolumeClaim{ {ObjectMeta: metav1.ObjectMeta{ Name: "instance1-abc-def", Labels: map[string]string{ @@ -1844,7 +1596,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { naming.LabelInstanceSet: "instance1"}}}}, []corev1.Pod{}, ), - fakeClusterVolumes: []corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{ + fakeClusterVolumes: []*corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{ Name: "instance1-abc-def", Labels: map[string]string{ naming.LabelRole: naming.RolePostgresData, @@ -1853,7 +1605,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { expectedInstanceNames: []string{"instance1-def"}, }, { set: v1beta1.PostgresInstanceSetSpec{Name: "instance1", - WALVolumeClaimSpec: &corev1.PersistentVolumeClaimSpec{}}, + WALVolumeClaimSpec: &v1beta1.VolumeClaimSpec{}}, fakeObservedInstances: newObservedInstances( &v1beta1.PostgresCluster{Spec: v1beta1.PostgresClusterSpec{ InstanceSets: []v1beta1.PostgresInstanceSetSpec{{Name: "instance1"}}, @@ -1864,7 +1616,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { naming.LabelInstanceSet: "instance1"}}}}, []corev1.Pod{}, ), - fakeClusterVolumes: []corev1.PersistentVolumeClaim{ + fakeClusterVolumes: []*corev1.PersistentVolumeClaim{ {ObjectMeta: metav1.ObjectMeta{ Name: "instance1-abc-def", Labels: map[string]string{ @@ -1880,7 +1632,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { expectedInstanceNames: []string{}, }, { set: v1beta1.PostgresInstanceSetSpec{Name: "instance1", - WALVolumeClaimSpec: &corev1.PersistentVolumeClaimSpec{}}, + WALVolumeClaimSpec: &v1beta1.VolumeClaimSpec{}}, fakeObservedInstances: newObservedInstances( &v1beta1.PostgresCluster{Spec: v1beta1.PostgresClusterSpec{ InstanceSets: []v1beta1.PostgresInstanceSetSpec{{Name: "instance1"}}, @@ -1888,7 +1640,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { []appsv1.StatefulSet{}, []corev1.Pod{}, ), - fakeClusterVolumes: []corev1.PersistentVolumeClaim{ + fakeClusterVolumes: []*corev1.PersistentVolumeClaim{ {ObjectMeta: metav1.ObjectMeta{ Name: "instance1-def-ghi", Labels: map[string]string{ @@ -1904,7 +1656,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { expectedInstanceNames: []string{"instance1-def"}, }, { set: v1beta1.PostgresInstanceSetSpec{Name: "instance1", - WALVolumeClaimSpec: &corev1.PersistentVolumeClaimSpec{}}, + WALVolumeClaimSpec: &v1beta1.VolumeClaimSpec{}}, fakeObservedInstances: newObservedInstances( &v1beta1.PostgresCluster{Spec: v1beta1.PostgresClusterSpec{ InstanceSets: []v1beta1.PostgresInstanceSetSpec{{Name: "instance1"}}, @@ -1912,7 +1664,7 @@ func TestFindAvailableInstanceNames(t *testing.T) { []appsv1.StatefulSet{}, []corev1.Pod{}, ), - fakeClusterVolumes: []corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{ + fakeClusterVolumes: []*corev1.PersistentVolumeClaim{{ObjectMeta: metav1.ObjectMeta{ Name: "instance1-def-ghi", Labels: map[string]string{ naming.LabelRole: naming.RolePostgresData, @@ -2150,3 +1902,286 @@ func TestCleanupDisruptionBudgets(t *testing.T) { }) }) } + +func TestReconcileInstanceConfigMap(t *testing.T) { + ctx := context.Background() + _, cc := setupKubernetes(t) + require.ParallelCapacity(t, 1) + + r := &Reconciler{ + Client: cc, + Owner: client.FieldOwner(t.Name()), + } + + t.Run("LocalVolumeOtelDisabled", func(t *testing.T) { + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-1" + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, true) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, true) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-1-instance-config") + assert.Equal(t, cm.Data["collector.yaml"], "") + assert.Equal(t, cm.Data["logrotate.conf"], "") + }) + + t.Run("CloudRepoOtelDisabled", func(t *testing.T) { + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-2" + cluster.Spec.Backups.PGBackRest.Repos = []v1beta1.PGBackRestRepo{{ + Name: "repo1", + GCS: &v1beta1.RepoGCS{ + Bucket: "test-bucket", + }, + }} + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, true) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, true) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-2-instance-config") + assert.Equal(t, cm.Data["collector.yaml"], "") + assert.Equal(t, cm.Data["logrotate.conf"], "") + }) + + t.Run("LocalVolumeOtelMetricsEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-3" + cluster.Spec.Instrumentation = &v1beta1.InstrumentationSpec{} + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, true) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, true) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-3-instance-config") + // We test the contents of the collector yaml elsewhere, I just want to + // make sure that it isn't empty here + assert.Assert(t, len(cm.Data["collector.yaml"]) > 0) + assert.Equal(t, cm.Data["logrotate.conf"], "") + }) + + t.Run("LocalVolumeOtelLogsEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-4" + cluster.Spec.Instrumentation = &v1beta1.InstrumentationSpec{} + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, true) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, true) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-4-instance-config") + // We test the contents of the collector and logrotate configs elsewhere, + // I just want to test that they aren't empty here + assert.Assert(t, len(cm.Data["collector.yaml"]) > 0) + assert.Assert(t, len(cm.Data["logrotate.conf"]) > 0) + }) + + t.Run("CloudRepoOtelMetricsEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-5" + cluster.Spec.Instrumentation = &v1beta1.InstrumentationSpec{} + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, true) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, true) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-5-instance-config") + // We test the contents of the collector yaml elsewhere, I just want to + // make sure that it isn't empty here + assert.Assert(t, len(cm.Data["collector.yaml"]) > 0) + assert.Equal(t, cm.Data["logrotate.conf"], "") + }) + + t.Run("CloudRepoOtelLogsEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-6" + cluster.Spec.Instrumentation = &v1beta1.InstrumentationSpec{} + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, true) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, true) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-6-instance-config") + // We test the contents of the collector and logrotate configs elsewhere, + // I just want to test that they aren't empty here + assert.Assert(t, len(cm.Data["collector.yaml"]) > 0) + assert.Assert(t, len(cm.Data["logrotate.conf"]) > 0) + }) + + t.Run("BackupsDisabledOtelDisabled", func(t *testing.T) { + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-7" + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, false) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, false) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-7-instance-config") + assert.Equal(t, cm.Data["collector.yaml"], "") + assert.Equal(t, cm.Data["logrotate.conf"], "") + }) + + t.Run("BackupsDisabledOtelMetricsEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-8" + cluster.Spec.Instrumentation = &v1beta1.InstrumentationSpec{} + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, false) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, false) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-8-instance-config") + assert.Assert(t, len(cm.Data["collector.yaml"]) > 0) + assert.Equal(t, cm.Data["logrotate.conf"], "") + }) + + t.Run("BackupsDisabledOtelLogsEnabled", func(t *testing.T) { + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryLogs: true, + })) + ctx := feature.NewContext(context.Background(), gate) + + ns := setupNamespace(t, cc) + cluster := testCluster() + cluster.Namespace = ns.Name + cluster.Name = "test-hippo-9" + cluster.Spec.Instrumentation = &v1beta1.InstrumentationSpec{} + assert.NilError(t, cc.Create(ctx, cluster)) + + spec := &v1beta1.PostgresInstanceSetSpec{} + instance := &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster.Name + "-instance", + Namespace: ns.Name, + }, + } + pgParameters := r.generatePostgresParameters(ctx, cluster, false) + otelConfig := collector.NewConfigForPostgresPod(ctx, cluster, pgParameters) + + cm, err := r.reconcileInstanceConfigMap(ctx, cluster, spec, instance, otelConfig, false) + assert.NilError(t, err) + assert.Equal(t, cm.Name, "test-hippo-9-instance-config") + assert.Assert(t, len(cm.Data["collector.yaml"]) > 0) + assert.Equal(t, cm.Data["logrotate.conf"], "") + }) +} diff --git a/internal/controller/postgrescluster/metrics_setup.sql b/internal/controller/postgrescluster/metrics_setup.sql new file mode 100644 index 0000000000..dbaee4f030 --- /dev/null +++ b/internal/controller/postgrescluster/metrics_setup.sql @@ -0,0 +1,381 @@ +-- +-- Copyright © 2017-2025 Crunchy Data Solutions, Inc. All Rights Reserved. +-- + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'ccp_monitoring') THEN + CREATE ROLE ccp_monitoring WITH LOGIN; + END IF; + + -- The pgmonitor role is required by the pgnodemx extension in PostgreSQL versions 9.5 and 9.6 + -- and should be removed when upgrading to PostgreSQL 10 and above. + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'pgmonitor') THEN + DROP ROLE pgmonitor; + END IF; +END +$$; + +GRANT pg_monitor to ccp_monitoring; +GRANT pg_execute_server_program TO ccp_monitoring; + +ALTER ROLE ccp_monitoring SET lock_timeout TO '2min'; +ALTER ROLE ccp_monitoring SET jit TO 'off'; + +CREATE SCHEMA IF NOT EXISTS monitor AUTHORIZATION ccp_monitoring; + +DROP TABLE IF EXISTS monitor.pg_stat_statements_reset_info; +-- Table to store last reset time for pg_stat_statements +CREATE TABLE monitor.pg_stat_statements_reset_info( + reset_time timestamptz +); + +DROP FUNCTION IF EXISTS monitor.pg_stat_statements_reset_info(int); +-- Function to reset pg_stat_statements periodically +CREATE FUNCTION monitor.pg_stat_statements_reset_info(p_throttle_minutes integer DEFAULT 1440) + RETURNS bigint + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path TO pg_catalog, pg_temp +AS $function$ +DECLARE + + v_reset_timestamp timestamptz; + v_throttle interval; + +BEGIN + + IF p_throttle_minutes < 0 THEN + RETURN 0; + END IF; + + v_throttle := make_interval(mins := p_throttle_minutes); + + SELECT COALESCE(max(reset_time), '1970-01-01'::timestamptz) INTO v_reset_timestamp FROM monitor.pg_stat_statements_reset_info; + + IF ((CURRENT_TIMESTAMP - v_reset_timestamp) > v_throttle) THEN + -- Ensure table is empty + DELETE FROM monitor.pg_stat_statements_reset_info; + PERFORM pg_stat_statements_reset(); + INSERT INTO monitor.pg_stat_statements_reset_info(reset_time) values (now()); + END IF; + + RETURN (SELECT extract(epoch from reset_time) FROM monitor.pg_stat_statements_reset_info); + +EXCEPTION + WHEN others then + RETURN 0; +END +$function$; + +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA monitor TO ccp_monitoring; +GRANT ALL ON ALL TABLES IN SCHEMA monitor TO ccp_monitoring; + +DROP FUNCTION IF EXISTS get_replication_lag(); +--- get_replication_lag is used by the OTel collector. +--- get_replication_lag is created as function, so that we can query without warning on a replica. +CREATE FUNCTION get_replication_lag() RETURNS TABLE(replica text, bytes NUMERIC) AS $$ +BEGIN + IF pg_is_in_recovery() THEN + RETURN QUERY SELECT ''::text as replica, 0::NUMERIC AS bytes; + ELSE + RETURN QUERY SELECT application_name AS replica, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS bytes + FROM pg_catalog.pg_stat_replication; + END IF; +END; +$$ LANGUAGE plpgsql; + +DROP FUNCTION IF EXISTS get_pgbackrest_info(); +--- get_pgbackrest_info is used by the OTel collector. +--- get_pgbackrest_info is created as a function so that no ddl runs on a replica. +--- In the query, the --stanza argument matches DefaultStanzaName, defined in internal/pgbackrest/config.go. +CREATE FUNCTION get_pgbackrest_info() +RETURNS TABLE ( + last_diff_backup BIGINT, + last_full_backup BIGINT, + last_incr_backup BIGINT, + last_info_backrest_repo_version TEXT, + last_info_backup_error INT, + backup_type TEXT, + backup_runtime_seconds BIGINT, + repo_backup_size_bytes TEXT, + oldest_full_backup BIGINT, + repo TEXT +) AS $$ +BEGIN + IF pg_is_in_recovery() THEN + RETURN QUERY + SELECT + 0::bigint AS last_diff_backup, + 0::bigint AS last_full_backup, + 0::bigint AS last_incr_backup, + '0' AS last_info_backrest_repo_version, + 0::int AS last_info_backup_error, + 'n/a'::text AS backup_type, + 0::bigint AS backup_runtime_seconds, + '0'::text AS repo_backup_size_bytes, + 0::bigint AS oldest_full_backup, + 'n/a' AS repo; + ELSE + DROP TABLE IF EXISTS pgbackrest_info; + CREATE TEMPORARY TABLE pgbackrest_info (data json); + COPY pgbackrest_info (data) + FROM PROGRAM 'export LC_ALL=C && printf "\f" && pgbackrest info --log-level-console=info --log-level-stderr=warn --output=json --stanza=db && printf "\f"' + WITH (FORMAT csv, HEADER false, QUOTE E'\f'); + + RETURN QUERY + WITH + all_backups (data) AS ( + SELECT jsonb_array_elements(to_jsonb(data)) FROM pgbackrest_info + ), + stanza_backups (stanza, backup) AS ( + SELECT data->>'name', jsonb_array_elements(data->'backup') FROM all_backups + ), + ordered_backups (stanza, backup, seq_oldest, seq_newest) AS ( + SELECT stanza, backup, + ROW_NUMBER() OVER ( + PARTITION BY stanza, backup->'database'->>'repo-key', backup->>'type' + ORDER BY backup->'timestamp'->>'start' ASC, backup->'timestamp'->>'stop' ASC + ), + ROW_NUMBER() OVER ( + PARTITION BY stanza, backup->'database'->>'repo-key', backup->>'type' + ORDER BY backup->'timestamp'->>'start' DESC, backup->'timestamp'->>'stop' DESC + ) + FROM stanza_backups + ), + + ccp_backrest_last_info AS ( + SELECT + stanza, + split_part(backup->'backrest'->>'version', '.', 1) || lpad(split_part(backup->'backrest'->>'version', '.', 2), 2, '0') || lpad(coalesce(nullif(split_part(backup->'backrest'->>'version', '.', 3), ''), '00'), 2, '0') AS backrest_repo_version, + backup->'database'->>'repo-key' AS repo, + backup->>'type' AS backup_type, + backup->'info'->'repository'->>'delta' AS repo_backup_size_bytes, + (backup->'timestamp'->>'stop')::bigint - (backup->'timestamp'->>'start')::bigint AS backup_runtime_seconds, + CASE WHEN backup->>'error' = 'true' THEN 1 ELSE 0 END AS backup_error + FROM ordered_backups + WHERE seq_newest = 1 + ), + + ccp_backrest_oldest_full_backup AS ( + SELECT + stanza, + backup->'database'->>'repo-key' AS repo, + min((backup->'timestamp'->>'stop')::bigint) AS time_seconds + FROM ordered_backups + WHERE seq_oldest = 1 AND backup->>'type' IN ('full') + GROUP BY 1,2 + ), + + ccp_backrest_last_full_backup AS ( + SELECT + stanza, + backup->'database'->>'repo-key' AS repo, + EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint - max((backup->'timestamp'->>'stop')::bigint) AS time_since_completion_seconds + FROM ordered_backups + WHERE seq_newest = 1 AND backup->>'type' IN ('full') + GROUP BY 1,2 + ), + + ccp_backrest_last_diff_backup AS ( + SELECT + stanza, + backup->'database'->>'repo-key' AS repo, + EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint - max((backup->'timestamp'->>'stop')::bigint) AS time_since_completion_seconds + FROM ordered_backups + WHERE seq_newest = 1 AND backup->>'type' IN ('full','diff') + GROUP BY 1,2 + ), + + ccp_backrest_last_incr_backup AS ( + SELECT + stanza, + backup->'database'->>'repo-key' AS repo, + EXTRACT(EPOCH FROM CURRENT_TIMESTAMP)::bigint - max((backup->'timestamp'->>'stop')::bigint) AS time_since_completion_seconds + FROM ordered_backups + WHERE seq_newest = 1 AND backup->>'type' IN ('full','diff','incr') + GROUP BY 1,2 + ) + + SELECT + ccp_backrest_last_diff_backup.time_since_completion_seconds, + ccp_backrest_last_full_backup.time_since_completion_seconds, + ccp_backrest_last_incr_backup.time_since_completion_seconds, + ccp_backrest_last_info.backrest_repo_version, + ccp_backrest_last_info.backup_error, + ccp_backrest_last_info.backup_type, + ccp_backrest_last_info.backup_runtime_seconds, + ccp_backrest_last_info.repo_backup_size_bytes, + ccp_backrest_oldest_full_backup.time_seconds, + ccp_backrest_last_incr_backup.repo + FROM + ccp_backrest_last_diff_backup + JOIN ccp_backrest_last_full_backup ON ccp_backrest_last_diff_backup.stanza = ccp_backrest_last_full_backup.stanza AND ccp_backrest_last_diff_backup.repo = ccp_backrest_last_full_backup.repo + JOIN ccp_backrest_last_incr_backup ON ccp_backrest_last_diff_backup.stanza = ccp_backrest_last_incr_backup.stanza AND ccp_backrest_last_diff_backup.repo = ccp_backrest_last_incr_backup.repo + JOIN ccp_backrest_last_info ON ccp_backrest_last_diff_backup.stanza = ccp_backrest_last_info.stanza AND ccp_backrest_last_diff_backup.repo = ccp_backrest_last_info.repo + JOIN ccp_backrest_oldest_full_backup ON ccp_backrest_last_diff_backup.stanza = ccp_backrest_oldest_full_backup.stanza AND ccp_backrest_last_diff_backup.repo = ccp_backrest_oldest_full_backup.repo; + END IF; +END; +$$ LANGUAGE plpgsql; + +/* +* The `pg_hba_checksum` table, functions, and view are taken from +* https://github.com/CrunchyData/pgmonitor/blob/development/postgres_exporter/common +* +* The goal of these table, functions, and view is to monitor changes +* to the pg_hba_file_rules system catalog. +* +* This material is used in the metric `ccp_pg_hba_checksum`. +*/ + +/* +* `monitor.pg_hba_checksum` table is used to store +* - the pg_hba settings as string (for reference) +* - the pg_hba settings as hash (for quick comparison) +* - the `hba_hash_known_provided` (for overide hash manually given to the `monitor.pg_hba_checksum` function) +* - the `valid` field to signal whether the pg_hba settings have not changed since they were accepted as valid +* +* We create an index on `created_at` in order to pull the most recent entry for +* comparison in the `monitor.pg_hba_checksum` function +*/ +DROP TABLE IF EXISTS monitor.pg_hba_checksum; +CREATE TABLE monitor.pg_hba_checksum ( + hba_hash_generated text NOT NULL + , hba_hash_known_provided text + , hba_string text NOT NULL + , created_at timestamptz DEFAULT now() NOT NULL + , valid smallint NOT NULL ); +COMMENT ON COLUMN monitor.pg_hba_checksum.valid IS 'Set this column to zero if this group of settings is a valid change'; +CREATE INDEX ON monitor.pg_hba_checksum (created_at); + +/* + * `monitor.pg_hba_checksum(text)` is used to compare the previous pg_hba hash + * with a hash made of the current pg_hba hash, derived from the `monitor.pg_hba_hash` view below. + * + * This function returns + * - 0, indicating NO settings have changed + * - 1, indicating something has changed since last known valid state + * + * `monitor.pg_hba_checksum` can take a hash to be used as an override. + * This may be useful when you have a standby with different pg_hba rules; + * since it will have different rules (and therefore a different hash), you + * could alter the metric function to pass the actual hash, which would be + * used in lieu of this table's value (derived from the primary cluster's rules). + */ +DROP FUNCTION IF EXISTS monitor.pg_hba_checksum(text); +CREATE FUNCTION monitor.pg_hba_checksum(p_known_hba_hash text DEFAULT NULL) + RETURNS smallint + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO pg_catalog, pg_temp +AS $function$ +DECLARE + +v_hba_hash text; +v_hba_hash_old text; +v_hba_string text; +v_is_in_recovery boolean; +v_valid smallint; + +BEGIN + +-- Retrieve the current settings from the `monitor.pg_hba_hash` view below +IF current_setting('server_version_num')::int >= 100000 THEN + SELECT sha256_hash, hba_string + INTO v_hba_hash, v_hba_string + FROM monitor.pg_hba_hash; +ELSE + RAISE EXCEPTION 'pg_hba change monitoring unsupported in versions older than PostgreSQL 10'; +END IF; + +-- Retrieve the last previous hash from the table +SELECT hba_hash_generated, valid +INTO v_hba_hash_old, v_valid +FROM monitor.pg_hba_checksum +ORDER BY created_at DESC LIMIT 1; + +-- If an manual/override hash has been given, we will use that: +-- Do not base validity on the stored value if manual hash is given. +IF p_known_hba_hash IS NOT NULL THEN + v_hba_hash_old := p_known_hba_hash; + v_valid := 0; +END IF; + +/* If the table is not empty or a manual hash was given, + * then we want to compare the old hash (from the table) + * with the new hash: if those differ, then we set the validity to 1; + * if they are the same, then we honor what the validity was + * in the table (which would be 1). + */ +IF (v_hba_hash_old IS NOT NULL) THEN + IF (v_hba_hash != v_hba_hash_old) THEN + v_valid := 1; + END IF; +ELSE + v_valid := 0; +END IF; + +/* + * We only want to insert into the table if we're on a primary and + * - the table/manually entered hash is empty, e.g., we've just started the cluster; or + * - the hashes don't match + * + * There's no value added by inserting into the table when no change was detected. + */ +IF (v_hba_hash_old IS NULL) OR (v_hba_hash != v_hba_hash_old) THEN + SELECT pg_is_in_recovery() INTO v_is_in_recovery; + IF v_is_in_recovery = false THEN + INSERT INTO monitor.pg_hba_checksum ( + hba_hash_generated + , hba_hash_known_provided + , hba_string + , valid) + VALUES ( + v_hba_hash + , p_known_hba_hash + , v_hba_string + , v_valid); + END IF; +END IF; + +RETURN v_valid; + +END +$function$; + +/* + * The `monitor.pg_hba_hash` view return both a hash and a string aggregate of the + * pg_catalog.pg_hba_file_rules. + * Note: We use `sha256` to hash to allow this to run on FIPS environments. + */ +DROP VIEW IF EXISTS monitor.pg_hba_hash; +CREATE VIEW monitor.pg_hba_hash AS + -- Order by line number so it's caught if no content is changed but the order of entries is changed + WITH hba_ordered_list AS ( + SELECT COALESCE(type, '<>') AS type + , array_to_string(COALESCE(database, ARRAY['<>']), ',') AS database + , array_to_string(COALESCE(user_name, ARRAY['<>']), ',') AS user_name + , COALESCE(address, '<>') AS address + , COALESCE(netmask, '<>') AS netmask + , COALESCE(auth_method, '<>') AS auth_method + , array_to_string(COALESCE(options, ARRAY['<>']), ',') AS options + FROM pg_catalog.pg_hba_file_rules + ORDER BY line_number) + SELECT sha256((string_agg(type||database||user_name||address||netmask||auth_method||options, ','))::bytea) AS sha256_hash + , string_agg(type||database||user_name||address||netmask||auth_method||options, ',') AS hba_string + FROM hba_ordered_list; + +/* + * The `monitor.pg_hba_checksum_set_valid` function provides an interface for resetting the + * checksum monitor. + * Note: configuration history will be cleared. + */ +DROP FUNCTION IF EXISTS monitor.pg_hba_checksum_set_valid(); +CREATE FUNCTION monitor.pg_hba_checksum_set_valid() RETURNS smallint + LANGUAGE sql +AS $function$ + +TRUNCATE monitor.pg_hba_checksum; + +SELECT monitor.pg_hba_checksum(); + +$function$; diff --git a/internal/controller/postgrescluster/patroni.go b/internal/controller/postgrescluster/patroni.go index 1c5ac93eed..af3a3b8cca 100644 --- a/internal/controller/postgrescluster/patroni.go +++ b/internal/controller/postgrescluster/patroni.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -94,7 +94,7 @@ func (r *Reconciler) handlePatroniRestarts( return r.PodExec(ctx, pod.Namespace, pod.Name, container, stdin, stdout, stderr, command...) }) - return errors.WithStack(exec.RestartPendingMembers(ctx, "master", naming.PatroniScope(cluster))) + return errors.WithStack(exec.RestartPendingMembers(ctx, "primary", naming.PatroniScope(cluster))) } // When the primary does not need to restart but a replica does, restart all @@ -173,7 +173,7 @@ func (r *Reconciler) reconcilePatroniDistributedConfiguration( func (r *Reconciler) reconcilePatroniDynamicConfiguration( ctx context.Context, cluster *v1beta1.PostgresCluster, instances *observedInstances, - pgHBAs postgres.HBAs, pgParameters postgres.Parameters, + pgHBAs *postgres.OrderedHBAs, pgParameters *postgres.ParameterSet, ) error { if !patroni.ClusterBootstrapped(cluster) { // Patroni has not yet bootstrapped. Dynamic configuration happens through @@ -204,14 +204,9 @@ func (r *Reconciler) reconcilePatroniDynamicConfiguration( return r.PodExec(ctx, pod.Namespace, pod.Name, naming.ContainerDatabase, stdin, stdout, stderr, command...) } - var configuration map[string]any - if cluster.Spec.Patroni != nil { - configuration = cluster.Spec.Patroni.DynamicConfiguration - } - configuration = patroni.DynamicConfiguration(cluster, configuration, pgHBAs, pgParameters) - return errors.WithStack( - patroni.Executor(exec).ReplaceConfiguration(ctx, configuration)) + patroni.Executor(exec).ReplaceConfiguration(ctx, + patroni.DynamicConfiguration(&cluster.Spec, pgHBAs, pgParameters))) } // generatePatroniLeaderLeaseService returns a v1.Service that exposes the @@ -276,6 +271,14 @@ func (r *Reconciler) generatePatroniLeaderLeaseService( } service.Spec.ExternalTrafficPolicy = initialize.FromPointer(spec.ExternalTrafficPolicy) service.Spec.InternalTrafficPolicy = spec.InternalTrafficPolicy + + // Set IPFamilyPolicy and IPFamilies + if spec.IPFamilyPolicy != nil { + service.Spec.IPFamilyPolicy = spec.IPFamilyPolicy + } + if len(spec.IPFamilies) > 0 { + service.Spec.IPFamilies = spec.IPFamilies + } } service.Spec.Ports = []corev1.ServicePort{servicePort} diff --git a/internal/controller/postgrescluster/patroni_test.go b/internal/controller/postgrescluster/patroni_test.go index b2a457685b..728b75aee3 100644 --- a/internal/controller/postgrescluster/patroni_test.go +++ b/internal/controller/postgrescluster/patroni_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,6 +6,7 @@ package postgrescluster import ( "context" + "errors" "fmt" "io" "os" @@ -14,7 +15,6 @@ import ( "testing" "time" - "github.com/pkg/errors" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -97,12 +97,12 @@ ownerReferences: assert.NilError(t, err) // Annotations present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, service.Annotations, map[string]string{ "a": "v1", }) // Labels present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, service.Labels, map[string]string{ "b": "v2", "postgres-operator.crunchydata.com/cluster": "pg2", "postgres-operator.crunchydata.com/patroni": "pg2-ha", @@ -125,13 +125,13 @@ ownerReferences: assert.NilError(t, err) // Annotations present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, service.Annotations, map[string]string{ "a": "v1", "c": "v3", }) // Labels present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, service.Labels, map[string]string{ "b": "v2", "d": "v4", "postgres-operator.crunchydata.com/cluster": "pg2", @@ -472,15 +472,15 @@ func TestReconcilePatroniStatus(t *testing.T) { ObjectMeta: naming.PatroniDistributedConfiguration(postgresCluster), } if writeAnnotation { - endpoints.ObjectMeta.Annotations = make(map[string]string) - endpoints.ObjectMeta.Annotations["initialize"] = systemIdentifier + endpoints.Annotations = make(map[string]string) + endpoints.Annotations["initialize"] = systemIdentifier } assert.NilError(t, tClient.Create(ctx, endpoints, &client.CreateOptions{})) instance := &Instance{ Name: instanceName, Runner: runner, } - for i := 0; i < readyReplicas; i++ { + for range readyReplicas { instance.Pods = append(instance.Pods, &corev1.Pod{ Status: corev1.PodStatus{ Conditions: []corev1.PodCondition{{ @@ -539,17 +539,17 @@ func TestReconcilePatroniSwitchover(t *testing.T) { switch { case timelineCall: timelineCall = false - stdout.Write([]byte(`[{"Cluster": "hippo-ha", "Member": "hippo-instance1-67mc-0", "Host": "hippo-instance1-67mc-0.hippo-pods", "Role": "Leader", "State": "running", "TL": 4}, {"Cluster": "hippo-ha", "Member": "hippo-instance1-ltcf-0", "Host": "hippo-instance1-ltcf-0.hippo-pods", "Role": "Replica", "State": "running", "TL": 4, "Lag in MB": 0}]`)) + _, _ = stdout.Write([]byte(`[{"Cluster": "hippo-ha", "Member": "hippo-instance1-67mc-0", "Host": "hippo-instance1-67mc-0.hippo-pods", "Role": "Leader", "State": "running", "TL": 4}, {"Cluster": "hippo-ha", "Member": "hippo-instance1-ltcf-0", "Host": "hippo-instance1-ltcf-0.hippo-pods", "Role": "Replica", "State": "running", "TL": 4, "Lag in MB": 0}]`)) case timelineCallNoLeader: - stdout.Write([]byte(`[{"Cluster": "hippo-ha", "Member": "hippo-instance1-ltcf-0", "Host": "hippo-instance1-ltcf-0.hippo-pods", "Role": "Replica", "State": "running", "TL": 4, "Lag in MB": 0}]`)) + _, _ = stdout.Write([]byte(`[{"Cluster": "hippo-ha", "Member": "hippo-instance1-ltcf-0", "Host": "hippo-instance1-ltcf-0.hippo-pods", "Role": "Replica", "State": "running", "TL": 4, "Lag in MB": 0}]`)) case callError: return errors.New("boom") case callFails: - stdout.Write([]byte("bang")) + _, _ = stdout.Write([]byte("bang")) case failover: - stdout.Write([]byte("failed over")) + _, _ = stdout.Write([]byte("failed over")) default: - stdout.Write([]byte("switched over")) + _, _ = stdout.Write([]byte("switched over")) } return nil }, diff --git a/internal/controller/postgrescluster/pgadmin.go b/internal/controller/postgrescluster/pgadmin.go index c0a936ba1f..dbaaf359ee 100644 --- a/internal/controller/postgrescluster/pgadmin.go +++ b/internal/controller/postgrescluster/pgadmin.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -183,6 +183,14 @@ func (r *Reconciler) generatePGAdminService( } service.Spec.ExternalTrafficPolicy = initialize.FromPointer(spec.ExternalTrafficPolicy) service.Spec.InternalTrafficPolicy = spec.InternalTrafficPolicy + + // Set IPFamilyPolicy and IPFamilies + if spec.IPFamilyPolicy != nil { + service.Spec.IPFamilyPolicy = spec.IPFamilyPolicy + } + if len(spec.IPFamilies) > 0 { + service.Spec.IPFamilies = spec.IPFamilies + } } service.Spec.Ports = []corev1.ServicePort{servicePort} @@ -258,7 +266,11 @@ func (r *Reconciler) reconcilePGAdminStatefulSet( } sts.Spec.Template.Annotations = naming.Merge( cluster.Spec.Metadata.GetAnnotationsOrNil(), - cluster.Spec.UserInterface.PGAdmin.Metadata.GetAnnotationsOrNil()) + cluster.Spec.UserInterface.PGAdmin.Metadata.GetAnnotationsOrNil(), + map[string]string{ + naming.DefaultContainerAnnotation: naming.ContainerPGAdmin, + }, + ) sts.Spec.Template.Labels = naming.Merge( cluster.Spec.Metadata.GetLabelsOrNil(), cluster.Spec.UserInterface.PGAdmin.Metadata.GetLabelsOrNil(), @@ -353,7 +365,7 @@ func (r *Reconciler) reconcilePGAdminStatefulSet( // add an emptyDir volume to the PodTemplateSpec and an associated '/tmp' // volume mount to all containers included within that spec - addTMPEmptyDir(&sts.Spec.Template) + AddTMPEmptyDir(&sts.Spec.Template) return errors.WithStack(r.apply(ctx, sts)) } @@ -393,7 +405,7 @@ func (r *Reconciler) reconcilePGAdminDataVolume( cluster.Spec.Metadata.GetLabelsOrNil(), labelMap, ) - pvc.Spec = cluster.Spec.UserInterface.PGAdmin.DataVolumeClaimSpec + pvc.Spec = cluster.Spec.UserInterface.PGAdmin.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() err := errors.WithStack(r.setControllerReference(cluster, pvc)) diff --git a/internal/controller/postgrescluster/pgadmin_test.go b/internal/controller/postgrescluster/pgadmin_test.go index 92ec6f42f1..1d0a305b2a 100644 --- a/internal/controller/postgrescluster/pgadmin_test.go +++ b/internal/controller/postgrescluster/pgadmin_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,11 +6,11 @@ package postgrescluster import ( "context" + "errors" "io" "strconv" "testing" - "github.com/pkg/errors" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -104,12 +104,12 @@ ownerReferences: assert.Assert(t, specified) // Annotations present in the metadata. - assert.DeepEqual(t, configmap.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, configmap.Annotations, map[string]string{ "a": "v5", "b": "v2", "e": "v6", }) // Labels present in the metadata. - assert.DeepEqual(t, configmap.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, configmap.Labels, map[string]string{ "c": "v7", "d": "v4", "f": "v8", "postgres-operator.crunchydata.com/cluster": "pg1", "postgres-operator.crunchydata.com/role": "pgadmin", @@ -194,12 +194,12 @@ ownerReferences: assert.Assert(t, specified) // Annotations present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, service.Annotations, map[string]string{ "a": "v1", }) // Labels present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, service.Labels, map[string]string{ "b": "v2", "postgres-operator.crunchydata.com/cluster": "my-cluster", "postgres-operator.crunchydata.com/role": "pgadmin", @@ -225,13 +225,13 @@ ownerReferences: assert.Assert(t, specified) // Annotations present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, service.Annotations, map[string]string{ "a": "v1", "c": "v3", }) // Labels present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, service.Labels, map[string]string{ "b": "v2", "d": "v4", "postgres-operator.crunchydata.com/cluster": "my-cluster", @@ -500,6 +500,8 @@ func TestReconcilePGAdminStatefulSet(t *testing.T) { template.Spec.Volumes = nil assert.Assert(t, cmp.MarshalMatches(template.ObjectMeta, ` +annotations: + kubectl.kubernetes.io/default-container: pgadmin creationTimestamp: null labels: postgres-operator.crunchydata.com/cluster: test-cluster @@ -613,6 +615,7 @@ terminationGracePeriodSeconds: 30 assert.Assert(t, cmp.MarshalMatches(template.ObjectMeta, ` annotations: annotation1: annotationvalue + kubectl.kubernetes.io/default-container: pgadmin creationTimestamp: null labels: label1: labelvalue @@ -850,7 +853,7 @@ func pgAdminTestCluster(ns corev1.Namespace) *v1beta1.PostgresCluster { Repos: []v1beta1.PGBackRestRepo{{ Name: "repo1", Volume: &v1beta1.RepoPVC{ - VolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + VolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{ Requests: corev1.ResourceList{ @@ -865,7 +868,7 @@ func pgAdminTestCluster(ns corev1.Namespace) *v1beta1.PostgresCluster { UserInterface: &v1beta1.UserInterfaceSpec{ PGAdmin: &v1beta1.PGAdminPodSpec{ Image: "test-image", - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, Resources: corev1.VolumeResourceRequirements{ Requests: corev1.ResourceList{ diff --git a/internal/controller/postgrescluster/pgbackrest.go b/internal/controller/postgrescluster/pgbackrest.go index 836df047fc..54b15ea8dc 100644 --- a/internal/controller/postgrescluster/pgbackrest.go +++ b/internal/controller/postgrescluster/pgbackrest.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -23,15 +23,13 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/types" utilerrors "k8s.io/apimachinery/pkg/util/errors" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/config" + "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/logging" @@ -40,6 +38,7 @@ import ( "github.com/crunchydata/postgres-operator/internal/pgbackrest" "github.com/crunchydata/postgres-operator/internal/pki" "github.com/crunchydata/postgres-operator/internal/postgres" + "github.com/crunchydata/postgres-operator/internal/util" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -125,9 +124,9 @@ type RepoResources struct { // strategy. func (r *Reconciler) applyRepoHostIntent(ctx context.Context, postgresCluster *v1beta1.PostgresCluster, repoHostName string, repoResources *RepoResources, - observedInstances *observedInstances) (*appsv1.StatefulSet, error) { + observedInstances *observedInstances, saName string) (*appsv1.StatefulSet, error) { - repo, err := r.generateRepoHostIntent(ctx, postgresCluster, repoHostName, repoResources, observedInstances) + repo, err := r.generateRepoHostIntent(ctx, postgresCluster, repoHostName, repoResources, observedInstances, saName) if err != nil { return nil, err } @@ -207,7 +206,7 @@ func (r *Reconciler) getPGBackRestResources(ctx context.Context, repoResources := &RepoResources{} - gvks := []schema.GroupVersionKind{{ + gvks := []runtime.GVK{{ Group: appsv1.SchemeGroupVersion.Group, Version: appsv1.SchemeGroupVersion.Version, Kind: "StatefulSetList", @@ -320,13 +319,9 @@ func (r *Reconciler) cleanupRepoResources(ctx context.Context, if !backupsSpecFound { break } - // Any resources from before 5.1 that relate to the previously required - // SSH configuration should be deleted. - // TODO(tjmoore4): This can be removed once 5.0 is EOL. - if owned.GetName() != naming.PGBackRestSSHConfig(postgresCluster).Name && - owned.GetName() != naming.PGBackRestSSHSecret(postgresCluster).Name { - // If a dedicated repo host resource and a dedicated repo host is enabled, then - // add to the slice and do not delete. + // If a dedicated repo host resource and a dedicated repo host is enabled, then + // add to the slice and do not delete. + if pgbackrest.RepoHostVolumeDefined(postgresCluster) { ownedNoDelete = append(ownedNoDelete, owned) delete = false } @@ -345,18 +340,6 @@ func (r *Reconciler) cleanupRepoResources(ctx context.Context, delete = false } } - case hasLabel(naming.LabelPGBackRestBackup): - if !backupsSpecFound { - break - } - // If a Job is identified for a repo that no longer exists in the spec then - // delete it. Otherwise add it to the slice and continue. - for _, repo := range postgresCluster.Spec.Backups.PGBackRest.Repos { - if repo.Name == owned.GetLabels()[naming.LabelPGBackRestRepo] { - ownedNoDelete = append(ownedNoDelete, owned) - delete = false - } - } case hasLabel(naming.LabelPGBackRestCronJob): if !backupsSpecFound { break @@ -371,6 +354,18 @@ func (r *Reconciler) cleanupRepoResources(ctx context.Context, break } } + case hasLabel(naming.LabelPGBackRestBackup): + if !backupsSpecFound { + break + } + // If a Job is identified for a repo that no longer exists in the spec then + // delete it. Otherwise add it to the slice and continue. + for _, repo := range postgresCluster.Spec.Backups.PGBackRest.Repos { + if repo.Name == owned.GetLabels()[naming.LabelPGBackRestRepo] { + ownedNoDelete = append(ownedNoDelete, owned) + delete = false + } + } case hasLabel(naming.LabelPGBackRestRestore): if !backupsSpecFound { break @@ -439,27 +434,24 @@ func unstructuredToRepoResources(kind string, repoResources *RepoResources, switch kind { case "StatefulSetList": - var stsList appsv1.StatefulSetList - if err := runtime.DefaultUnstructuredConverter. - FromUnstructured(uList.UnstructuredContent(), &stsList); err != nil { + stsList, err := runtime.FromUnstructuredList[appsv1.StatefulSetList](uList) + if err != nil { return errors.WithStack(err) } for i := range stsList.Items { repoResources.hosts = append(repoResources.hosts, &stsList.Items[i]) } case "CronJobList": - var cronList batchv1.CronJobList - if err := runtime.DefaultUnstructuredConverter. - FromUnstructured(uList.UnstructuredContent(), &cronList); err != nil { + cronList, err := runtime.FromUnstructuredList[batchv1.CronJobList](uList) + if err != nil { return errors.WithStack(err) } for i := range cronList.Items { repoResources.cronjobs = append(repoResources.cronjobs, &cronList.Items[i]) } case "JobList": - var jobList batchv1.JobList - if err := runtime.DefaultUnstructuredConverter. - FromUnstructured(uList.UnstructuredContent(), &jobList); err != nil { + jobList, err := runtime.FromUnstructuredList[batchv1.JobList](uList) + if err != nil { return errors.WithStack(err) } // we care about replica create backup jobs and manual backup jobs @@ -477,9 +469,8 @@ func unstructuredToRepoResources(kind string, repoResources *RepoResources, // Repository host now uses mTLS for encryption, authentication, and authorization. // Configmaps for SSHD are no longer managed here. case "PersistentVolumeClaimList": - var pvcList corev1.PersistentVolumeClaimList - if err := runtime.DefaultUnstructuredConverter. - FromUnstructured(uList.UnstructuredContent(), &pvcList); err != nil { + pvcList, err := runtime.FromUnstructuredList[corev1.PersistentVolumeClaimList](uList) + if err != nil { return errors.WithStack(err) } for i := range pvcList.Items { @@ -491,27 +482,24 @@ func unstructuredToRepoResources(kind string, repoResources *RepoResources, // TODO(tjmoore4): Consider adding all pgBackRest secrets to RepoResources to // observe all pgBackRest secrets in one place. case "ServiceAccountList": - var saList corev1.ServiceAccountList - if err := runtime.DefaultUnstructuredConverter. - FromUnstructured(uList.UnstructuredContent(), &saList); err != nil { + saList, err := runtime.FromUnstructuredList[corev1.ServiceAccountList](uList) + if err != nil { return errors.WithStack(err) } for i := range saList.Items { repoResources.sas = append(repoResources.sas, &saList.Items[i]) } case "RoleList": - var roleList rbacv1.RoleList - if err := runtime.DefaultUnstructuredConverter. - FromUnstructured(uList.UnstructuredContent(), &roleList); err != nil { + roleList, err := runtime.FromUnstructuredList[rbacv1.RoleList](uList) + if err != nil { return errors.WithStack(err) } for i := range roleList.Items { repoResources.roles = append(repoResources.roles, &roleList.Items[i]) } case "RoleBindingList": - var rb rbacv1.RoleBindingList - if err := runtime.DefaultUnstructuredConverter. - FromUnstructured(uList.UnstructuredContent(), &rb); err != nil { + rb, err := runtime.FromUnstructuredList[rbacv1.RoleBindingList](uList) + if err != nil { return errors.WithStack(err) } for i := range rb.Items { @@ -532,9 +520,8 @@ func (r *Reconciler) setScheduledJobStatus(ctx context.Context, log := logging.FromContext(ctx) uList := &unstructured.UnstructuredList{Items: items} - var jobList batchv1.JobList - if err := runtime.DefaultUnstructuredConverter. - FromUnstructured(uList.UnstructuredContent(), &jobList); err != nil { + jobList, err := runtime.FromUnstructuredList[batchv1.JobList](uList) + if err != nil { // as this is only setting a status that is not otherwise used // by the Operator, simply log an error and return rather than // bubble this up to the other functions @@ -578,12 +565,16 @@ func (r *Reconciler) setScheduledJobStatus(ctx context.Context, // as needed to create and reconcile a pgBackRest dedicated repository host within the kubernetes // cluster. func (r *Reconciler) generateRepoHostIntent(ctx context.Context, postgresCluster *v1beta1.PostgresCluster, - repoHostName string, repoResources *RepoResources, observedInstances *observedInstances, + repoHostName string, repoResources *RepoResources, observedInstances *observedInstances, saName string, ) (*appsv1.StatefulSet, error) { annotations := naming.Merge( postgresCluster.Spec.Metadata.GetAnnotationsOrNil(), - postgresCluster.Spec.Backups.PGBackRest.Metadata.GetAnnotationsOrNil()) + postgresCluster.Spec.Backups.PGBackRest.Metadata.GetAnnotationsOrNil(), + map[string]string{ + naming.DefaultContainerAnnotation: naming.PGBackRestRepoContainerName, + }, + ) labels := naming.Merge( postgresCluster.Spec.Metadata.GetLabelsOrNil(), postgresCluster.Spec.Backups.PGBackRest.Metadata.GetLabelsOrNil(), @@ -617,7 +608,8 @@ func (r *Reconciler) generateRepoHostIntent(ctx context.Context, postgresCluster }, } - if repoHost := postgresCluster.Spec.Backups.PGBackRest.RepoHost; repoHost != nil { + repoHost := postgresCluster.Spec.Backups.PGBackRest.RepoHost + if repoHost != nil { repo.Spec.Template.Spec.Affinity = repoHost.Affinity repo.Spec.Template.Spec.Tolerations = repoHost.Tolerations repo.Spec.Template.Spec.TopologySpreadConstraints = repoHost.TopologySpreadConstraints @@ -688,19 +680,33 @@ func (r *Reconciler) generateRepoHostIntent(ctx context.Context, postgresCluster repo.Spec.Template.Spec.SecurityContext = postgres.PodSecurityContext(postgresCluster) + repo.Spec.Template.Spec.ServiceAccountName = saName + pgbackrest.AddServerToRepoPod(ctx, postgresCluster, &repo.Spec.Template.Spec) - if pgbackrest.RepoHostVolumeDefined(postgresCluster) { - // add the init container to make the pgBackRest repo volume log directory - pgbackrest.MakePGBackrestLogDir(&repo.Spec.Template, postgresCluster) + // add the init container to make the pgBackRest repo volume log directory + pgBackRestLogPath := pgbackrest.MakePGBackrestLogDir(&repo.Spec.Template, postgresCluster) - // add pgBackRest repo volumes to pod - if err := pgbackrest.AddRepoVolumesToPod(postgresCluster, &repo.Spec.Template, - getRepoPVCNames(postgresCluster, repoResources.pvcs), - naming.PGBackRestRepoContainerName); err != nil { - return nil, errors.WithStack(err) - } + containersToAdd := []string{naming.PGBackRestRepoContainerName} + + // If OpenTelemetryLogs is enabled, we want to add the collector to the pod + // and also add the RepoVolumes to the container. + if collector.OpenTelemetryLogsEnabled(ctx, postgresCluster) { + collector.AddToPod(ctx, postgresCluster.Spec.Instrumentation, postgresCluster.Spec.ImagePullPolicy, + &corev1.ConfigMap{ObjectMeta: naming.PGBackRestConfig(postgresCluster)}, + &repo.Spec.Template, []corev1.VolumeMount{}, "", + []string{pgBackRestLogPath}, true, false) + + containersToAdd = append(containersToAdd, naming.ContainerCollector) + } + + // add pgBackRest repo volumes to pod and to containers + if err := pgbackrest.AddRepoVolumesToPod(postgresCluster, &repo.Spec.Template, + getRepoPVCNames(postgresCluster, repoResources.pvcs), + containersToAdd...); err != nil { + return nil, errors.WithStack(err) } + // add configs to pod pgbackrest.AddConfigToRepoPod(postgresCluster, &repo.Spec.Template.Spec) @@ -711,11 +717,20 @@ func (r *Reconciler) generateRepoHostIntent(ctx context.Context, postgresCluster postgresCluster.Spec.ImagePullPolicy, &repo.Spec.Template) - addTMPEmptyDir(&repo.Spec.Template) + AddTMPEmptyDir(&repo.Spec.Template) + + // mount additional volumes to the repo host containers + if repoHost != nil && repoHost.Volumes != nil && len(repoHost.Volumes.Additional) > 0 { + missingContainers := addAdditionalVolumesToSpecifiedContainers(&repo.Spec.Template, repoHost.Volumes.Additional) + + if len(missingContainers) > 0 { + r.Recorder.Eventf(postgresCluster, corev1.EventTypeWarning, "SpecifiedContainerNotFound", + "The following Repo Host Pod containers were specified for additional volumes but cannot be found: %s.", missingContainers) + } + } // set ownership references - if err := controllerutil.SetControllerReference(postgresCluster, repo, - r.Client.Scheme()); err != nil { + if err := r.setControllerReference(postgresCluster, repo); err != nil { return nil, err } @@ -760,8 +775,7 @@ func (r *Reconciler) generateRepoVolumeIntent(postgresCluster *v1beta1.PostgresC } // set ownership references - if err := controllerutil.SetControllerReference(postgresCluster, repoVol, - r.Client.Scheme()); err != nil { + if err := r.setControllerReference(postgresCluster, repoVol); err != nil { return nil, err } @@ -769,7 +783,7 @@ func (r *Reconciler) generateRepoVolumeIntent(postgresCluster *v1beta1.PostgresC } // generateBackupJobSpecIntent generates a JobSpec for a pgBackRest backup job -func generateBackupJobSpecIntent(ctx context.Context, postgresCluster *v1beta1.PostgresCluster, +func (r *Reconciler) generateBackupJobSpecIntent(ctx context.Context, postgresCluster *v1beta1.PostgresCluster, repo v1beta1.PGBackRestRepo, serviceAccountName string, labels, annotations map[string]string, opts ...string) *batchv1.JobSpec { @@ -786,25 +800,44 @@ func generateBackupJobSpecIntent(ctx context.Context, postgresCluster *v1beta1.P cmdOpts = append(cmdOpts, opts...) container := corev1.Container{ - Command: []string{"/opt/crunchy/bin/pgbackrest"}, - Env: []corev1.EnvVar{ + Image: config.PGBackRestContainerImage(postgresCluster), + ImagePullPolicy: postgresCluster.Spec.ImagePullPolicy, + Name: naming.PGBackRestRepoContainerName, + SecurityContext: initialize.RestrictedSecurityContext(), + } + + // If the repo that we are backing up to is a local volume, we will configure + // the job to use the pgbackrest go binary to exec into the repo host and run + // the backup. If the repo is a cloud-based repo, we will run the pgbackrest + // backup command directly in the job pod. + if repo.Volume != nil { + container.Command = []string{"/opt/crunchy/bin/pgbackrest"} + container.Env = []corev1.EnvVar{ {Name: "COMMAND", Value: "backup"}, {Name: "COMMAND_OPTS", Value: strings.Join(cmdOpts, " ")}, {Name: "COMPARE_HASH", Value: "true"}, {Name: "CONTAINER", Value: naming.PGBackRestRepoContainerName}, {Name: "NAMESPACE", Value: postgresCluster.GetNamespace()}, {Name: "SELECTOR", Value: naming.PGBackRestDedicatedSelector(postgresCluster.GetName()).String()}, - }, - Image: config.PGBackRestContainerImage(postgresCluster), - ImagePullPolicy: postgresCluster.Spec.ImagePullPolicy, - Name: naming.PGBackRestRepoContainerName, - SecurityContext: initialize.RestrictedSecurityContext(), + } + } else { + container.Command = []string{"/bin/pgbackrest", "backup"} + container.Command = append(container.Command, cmdOpts...) } - if postgresCluster.Spec.Backups.PGBackRest.Jobs != nil { + jobs := postgresCluster.Spec.Backups.PGBackRest.Jobs + if jobs != nil { container.Resources = postgresCluster.Spec.Backups.PGBackRest.Jobs.Resources } + if annotations != nil { + annotations[naming.DefaultContainerAnnotation] = naming.PGBackRestRepoContainerName + } else { + annotations = map[string]string{ + naming.DefaultContainerAnnotation: naming.PGBackRestRepoContainerName, + } + } + jobSpec := &batchv1.JobSpec{ Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: annotations}, @@ -827,12 +860,9 @@ func generateBackupJobSpecIntent(ctx context.Context, postgresCluster *v1beta1.P }, } - if jobs := postgresCluster.Spec.Backups.PGBackRest.Jobs; jobs != nil { + // set the job lifetime, priority class name, tolerations, and affinity, if they exist + if jobs != nil { jobSpec.TTLSecondsAfterFinished = jobs.TTLSecondsAfterFinished - } - - // set the priority class name, tolerations, and affinity, if they exist - if postgresCluster.Spec.Backups.PGBackRest.Jobs != nil { jobSpec.Template.Spec.Tolerations = postgresCluster.Spec.Backups.PGBackRest.Jobs.Tolerations jobSpec.Template.Spec.Affinity = postgresCluster.Spec.Backups.PGBackRest.Jobs.Affinity jobSpec.Template.Spec.PriorityClassName = @@ -846,7 +876,45 @@ func generateBackupJobSpecIntent(ctx context.Context, postgresCluster *v1beta1.P jobSpec.Template.Spec.ImagePullSecrets = postgresCluster.Spec.ImagePullSecrets // add pgBackRest configs to template - pgbackrest.AddConfigToRepoPod(postgresCluster, &jobSpec.Template.Spec) + if repo.Volume != nil { + pgbackrest.AddConfigToRepoPod(postgresCluster, &jobSpec.Template.Spec) + } else { + // If we are doing a cloud repo backup, we need to give pgbackrest proper permissions + // to read certificate files + jobSpec.Template.Spec.SecurityContext = postgres.PodSecurityContext(postgresCluster) + pgbackrest.AddConfigToCloudBackupJob(postgresCluster, &jobSpec.Template) + + // If the user has specified a PVC to use as a log volume via the PGBackRestCloudLogVolume + // annotation, check for the PVC. If we find it, mount it to the backup job. + // Otherwise, create a warning event. + if logVolumeName := postgresCluster.Annotations[naming.PGBackRestCloudLogVolume]; logVolumeName != "" { + logVolume := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: logVolumeName, + Namespace: postgresCluster.GetNamespace(), + }, + } + err := errors.WithStack(r.Client.Get(ctx, + client.ObjectKeyFromObject(logVolume), logVolume)) + if err != nil { + // PVC not retrieved, create warning event + r.Recorder.Event(postgresCluster, corev1.EventTypeWarning, "PGBackRestCloudLogVolumeNotFound", err.Error()) + } else { + // We successfully found the specified PVC, so we will add it to the backup job + util.AddVolumeAndMountsToPod(&jobSpec.Template.Spec, logVolume) + } + } + } + + // mount additional volumes to the job containers + if jobs != nil && jobs.Volumes != nil && len(jobs.Volumes.Additional) > 0 { + missingContainers := addAdditionalVolumesToSpecifiedContainers(&jobSpec.Template, jobs.Volumes.Additional) + + if len(missingContainers) > 0 { + r.Recorder.Eventf(postgresCluster, corev1.EventTypeWarning, "SpecifiedContainerNotFound", + "The following Backup Job Pod containers were specified for additional volumes but cannot be found: %s.", missingContainers) + } + } return jobSpec } @@ -1151,10 +1219,16 @@ func (r *Reconciler) reconcileRestoreJob(ctx context.Context, "--pg1-path=" + pgdata, "--repo=" + regexRepoIndex.FindString(repoName)}...) + // Look specifically for the "--target" flag, NOT flags that contain + // "--target" (e.g. "--target-timeline") + targetRegex, err := regexp.Compile("--target[ =]") + if err != nil { + return err + } var deltaOptFound, foundTarget bool for _, opt := range opts { switch { - case strings.Contains(opt, "--target"): + case targetRegex.MatchString(opt): foundTarget = true case strings.Contains(opt, "--delta"): deltaOptFound = true @@ -1257,7 +1331,7 @@ func (r *Reconciler) reconcileRestoreJob(ctx context.Context, cluster.Spec.ImagePullPolicy, &restoreJob.Spec.Template) - addTMPEmptyDir(&restoreJob.Spec.Template) + AddTMPEmptyDir(&restoreJob.Spec.Template) return errors.WithStack(r.apply(ctx, restoreJob)) } @@ -1272,7 +1346,10 @@ func (r *Reconciler) generateRestoreJobIntent(cluster *v1beta1.PostgresCluster, annotations := naming.Merge( cluster.Spec.Metadata.GetAnnotationsOrNil(), cluster.Spec.Backups.PGBackRest.Metadata.GetAnnotationsOrNil(), - map[string]string{naming.PGBackRestConfigHash: configHash}) + map[string]string{ + naming.PGBackRestConfigHash: configHash, + naming.DefaultContainerAnnotation: naming.PGBackRestRestoreContainerName, + }) labels := naming.Merge( cluster.Spec.Metadata.GetLabelsOrNil(), cluster.Spec.Backups.PGBackRest.Metadata.GetLabelsOrNil(), @@ -1330,6 +1407,15 @@ func (r *Reconciler) generateRestoreJobIntent(cluster *v1beta1.PostgresCluster, // set the priority class name, if it exists job.Spec.Template.Spec.PriorityClassName = initialize.FromPointer(dataSource.PriorityClassName) + if dataSource.Volumes != nil && len(dataSource.Volumes.Additional) > 0 { + missingContainers := addAdditionalVolumesToSpecifiedContainers(&job.Spec.Template, dataSource.Volumes.Additional) + + if len(missingContainers) > 0 { + r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "SpecifiedContainerNotFound", + "The following Restore Pod containers were specified for additional volumes but cannot be found: %s.", missingContainers) + } + } + job.SetGroupVersionKind(batchv1.SchemeGroupVersion.WithKind("Job")) if err := errors.WithStack(r.setControllerReference(cluster, job)); err != nil { return err @@ -1378,16 +1464,29 @@ func (r *Reconciler) reconcilePGBackRest(ctx context.Context, return result, nil } - var repoHost *appsv1.StatefulSet - var repoHostName string - // reconcile the pgbackrest repository host - repoHost, err = r.reconcileDedicatedRepoHost(ctx, postgresCluster, repoResources, instances) + // reconcile the RBAC required to run the pgBackRest Repo Host + repoHostSA, err := r.reconcileRepoHostRBAC(ctx, postgresCluster) if err != nil { - log.Error(err, "unable to reconcile pgBackRest repo host") + log.Error(err, "unable to reconcile pgBackRest repo host RBAC") result.Requeue = true return result, nil } - repoHostName = repoHost.GetName() + + var repoHost *appsv1.StatefulSet + var repoHostName string + if pgbackrest.RepoHostVolumeDefined(postgresCluster) { + // reconcile the pgbackrest repository host + repoHost, err = r.reconcileDedicatedRepoHost(ctx, postgresCluster, repoResources, instances, repoHostSA.GetName()) + if err != nil { + log.Error(err, "unable to reconcile pgBackRest repo host") + result.Requeue = true + return result, nil + } + repoHostName = repoHost.GetName() + } else { + // remove the dedicated repo host status if a dedicated host is not enabled + meta.RemoveStatusCondition(&postgresCluster.Status.Conditions, ConditionRepoHostReady) + } if err := r.reconcilePGBackRestSecret(ctx, postgresCluster, repoHost, rootCA); err != nil { log.Error(err, "unable to reconcile pgBackRest secret") @@ -1497,7 +1596,7 @@ func (r *Reconciler) reconcilePGBackRest(ctx context.Context, // for the PostgresCluster being reconciled using the backups of another PostgresCluster. func (r *Reconciler) reconcilePostgresClusterDataSource(ctx context.Context, cluster *v1beta1.PostgresCluster, dataSource *v1beta1.PostgresClusterDataSource, - configHash string, clusterVolumes []corev1.PersistentVolumeClaim, + configHash string, clusterVolumes []*corev1.PersistentVolumeClaim, rootCA *pki.RootCertificateAuthority, backupsSpecFound bool, ) error { @@ -1661,7 +1760,7 @@ func (r *Reconciler) reconcilePostgresClusterDataSource(ctx context.Context, // data source, i.e., S3, etc. func (r *Reconciler) reconcileCloudBasedDataSource(ctx context.Context, cluster *v1beta1.PostgresCluster, dataSource *v1beta1.PGBackRestDataSource, - configHash string, clusterVolumes []corev1.PersistentVolumeClaim) error { + configHash string, clusterVolumes []*corev1.PersistentVolumeClaim) error { // Ensure the proper instance and instance set can be identified via the status. The // StartupInstance and StartupInstanceSet values should be populated when the cluster @@ -1750,6 +1849,7 @@ func (r *Reconciler) reconcileCloudBasedDataSource(ctx context.Context, Affinity: dataSource.Affinity, Tolerations: dataSource.Tolerations, PriorityClassName: dataSource.PriorityClassName, + Volumes: dataSource.Volumes, } // reconcile the pgBackRest restore Job to populate the cluster's data directory @@ -1878,7 +1978,7 @@ func (r *Reconciler) copyConfigurationResources(ctx context.Context, cluster, if sourceCluster.Spec.Backups.PGBackRest.Configuration[i].Secret != nil { secretProjection := sourceCluster.Spec.Backups.PGBackRest.Configuration[i].Secret secretCopy := &corev1.Secret{} - secretName := types.NamespacedName{ + secretName := client.ObjectKey{ Name: secretProjection.Name, Namespace: sourceCluster.Namespace, } @@ -1932,7 +2032,7 @@ func (r *Reconciler) copyConfigurationResources(ctx context.Context, cluster, if sourceCluster.Spec.Backups.PGBackRest.Configuration[i].ConfigMap != nil { configMapProjection := sourceCluster.Spec.Backups.PGBackRest.Configuration[i].ConfigMap configMapCopy := &corev1.ConfigMap{} - configMapName := types.NamespacedName{ + configMapName := client.ObjectKey{ Name: configMapProjection.Name, Namespace: sourceCluster.Namespace, } @@ -1985,16 +2085,42 @@ func (r *Reconciler) copyConfigurationResources(ctx context.Context, cluster, return nil } -// reconcilePGBackRestConfig is responsible for reconciling the pgBackRest ConfigMaps and Secrets. +// reconcilePGBackRestConfig is responsible for reconciling the pgBackRest ConfigMaps. func (r *Reconciler) reconcilePGBackRestConfig(ctx context.Context, postgresCluster *v1beta1.PostgresCluster, repoHostName, configHash, serviceName, serviceNamespace string, instanceNames []string) error { - backrestConfig := pgbackrest.CreatePGBackRestConfigMapIntent(postgresCluster, repoHostName, - configHash, serviceName, serviceNamespace, instanceNames) - if err := controllerutil.SetControllerReference(postgresCluster, backrestConfig, - r.Client.Scheme()); err != nil { + // If the user has specified a PVC to use as a log volume for cloud backups via the + // PGBackRestCloudLogVolume annotation, check for the PVC. If we find it, set the cloud + // log path. If the user has specified a PVC, but we can't find it, create a warning event. + cloudLogPath := "" + if logVolumeName := postgresCluster.Annotations[naming.PGBackRestCloudLogVolume]; logVolumeName != "" { + logVolume := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: logVolumeName, + Namespace: postgresCluster.GetNamespace(), + }, + } + err := errors.WithStack(r.Client.Get(ctx, + client.ObjectKeyFromObject(logVolume), logVolume)) + if err != nil { + // PVC not retrieved, create warning event + r.Recorder.Event(postgresCluster, corev1.EventTypeWarning, + "PGBackRestCloudLogVolumeNotFound", err.Error()) + } else { + // We successfully found the specified PVC, so we will set the log path + cloudLogPath = "/volumes/" + logVolumeName + } + } + + backrestConfig, err := pgbackrest.CreatePGBackRestConfigMapIntent(ctx, postgresCluster, repoHostName, + configHash, serviceName, serviceNamespace, cloudLogPath, instanceNames) + if err != nil { + return err + } + + if err := r.setControllerReference(postgresCluster, backrestConfig); err != nil { return err } if err := r.apply(ctx, backrestConfig); err != nil { @@ -2117,12 +2243,39 @@ func (r *Reconciler) reconcilePGBackRestRBAC(ctx context.Context, return sa, nil } +// +kubebuilder:rbac:groups="",resources="serviceaccounts",verbs={create,patch} + +// reconcileRepoHostRBAC reconciles the ServiceAccount for the pgBackRest repo host +func (r *Reconciler) reconcileRepoHostRBAC(ctx context.Context, + postgresCluster *v1beta1.PostgresCluster) (*corev1.ServiceAccount, error) { + + sa := &corev1.ServiceAccount{ObjectMeta: naming.RepoHostRBAC(postgresCluster)} + sa.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ServiceAccount")) + + if err := r.setControllerReference(postgresCluster, sa); err != nil { + return nil, errors.WithStack(err) + } + + sa.Annotations = naming.Merge(postgresCluster.Spec.Metadata.GetAnnotationsOrNil(), + postgresCluster.Spec.Backups.PGBackRest.Metadata.GetAnnotationsOrNil()) + sa.Labels = naming.Merge(postgresCluster.Spec.Metadata.GetLabelsOrNil(), + postgresCluster.Spec.Backups.PGBackRest.Metadata.GetLabelsOrNil(), + naming.PGBackRestLabels(postgresCluster.GetName())) + + if err := r.apply(ctx, sa); err != nil { + return nil, errors.WithStack(err) + } + + return sa, nil +} + // reconcileDedicatedRepoHost is responsible for reconciling a pgBackRest dedicated repository host // StatefulSet according to a specific PostgresCluster custom resource. func (r *Reconciler) reconcileDedicatedRepoHost(ctx context.Context, postgresCluster *v1beta1.PostgresCluster, repoResources *RepoResources, - observedInstances *observedInstances) (*appsv1.StatefulSet, error) { + observedInstances *observedInstances, + saName string) (*appsv1.StatefulSet, error) { log := logging.FromContext(ctx).WithValues("reconcileResource", "repoHost") @@ -2163,7 +2316,7 @@ func (r *Reconciler) reconcileDedicatedRepoHost(ctx context.Context, } repoHostName := repoResources.hosts[0].Name repoHost, err := r.applyRepoHostIntent(ctx, postgresCluster, repoHostName, repoResources, - observedInstances) + observedInstances, saName) if err != nil { log.Error(err, "reconciling repository host") return nil, err @@ -2173,7 +2326,7 @@ func (r *Reconciler) reconcileDedicatedRepoHost(ctx context.Context, if isCreate { r.Recorder.Eventf(postgresCluster, corev1.EventTypeNormal, EventRepoHostCreated, - "created pgBackRest repository host %s/%s", repoHost.TypeMeta.Kind, repoHostName) + "created pgBackRest repository host %s/%s", repoHost.Kind, repoHostName) } return repoHost, nil @@ -2287,11 +2440,13 @@ func (r *Reconciler) reconcileManualBackup(ctx context.Context, return nil } - // determine if the dedicated repository host is ready using the repo host ready + // determine if the dedicated repository host is ready (if enabled) using the repo host ready // condition, and return if not - repoCondition := meta.FindStatusCondition(postgresCluster.Status.Conditions, ConditionRepoHostReady) - if repoCondition == nil || repoCondition.Status != metav1.ConditionTrue { - return nil + if pgbackrest.RepoHostVolumeDefined(postgresCluster) { + repoCondition := meta.FindStatusCondition(postgresCluster.Status.Conditions, ConditionRepoHostReady) + if repoCondition == nil || repoCondition.Status != metav1.ConditionTrue { + return nil + } } // Determine if the replica create backup is complete and return if not. This allows for proper @@ -2357,7 +2512,7 @@ func (r *Reconciler) reconcileManualBackup(ctx context.Context, backupJob := &batchv1.Job{} backupJob.ObjectMeta = naming.PGBackRestBackupJob(postgresCluster) if currentBackupJob != nil { - backupJob.ObjectMeta.Name = currentBackupJob.ObjectMeta.Name + backupJob.Name = currentBackupJob.Name } var labels, annotations map[string]string @@ -2370,18 +2525,17 @@ func (r *Reconciler) reconcileManualBackup(ctx context.Context, map[string]string{ naming.PGBackRestBackup: manualAnnotation, }) - backupJob.ObjectMeta.Labels = labels - backupJob.ObjectMeta.Annotations = annotations + backupJob.Labels = labels + backupJob.Annotations = annotations - spec := generateBackupJobSpecIntent(ctx, postgresCluster, repo, + spec := r.generateBackupJobSpecIntent(ctx, postgresCluster, repo, serviceAccount.GetName(), labels, annotations, backupOpts...) backupJob.Spec = *spec // set gvk and ownership refs backupJob.SetGroupVersionKind(batchv1.SchemeGroupVersion.WithKind("Job")) - if err := controllerutil.SetControllerReference(postgresCluster, backupJob, - r.Client.Scheme()); err != nil { + if err := r.setControllerReference(postgresCluster, backupJob); err != nil { return errors.WithStack(err) } @@ -2463,6 +2617,17 @@ func (r *Reconciler) reconcileReplicaCreateBackup(ctx context.Context, replicaRepoReady = (condition.Status == metav1.ConditionTrue) } + // TODO: Since we now only exec into the repo host when backing up to a local volume and + // run the backup in the job pod when backing up to a cloud-based repo, we should consider + // using a different value than the container name for the "pgbackrest-config" annotation + // that we attach to these backups + var containerName string + if replicaCreateRepo.Volume != nil { + containerName = naming.PGBackRestRepoContainerName + } else { + containerName = naming.ContainerDatabase + } + // determine if the dedicated repository host is ready using the repo host ready status var dedicatedRepoReady bool condition = meta.FindStatusCondition(postgresCluster.Status.Conditions, ConditionRepoHostReady) @@ -2489,10 +2654,14 @@ func (r *Reconciler) reconcileReplicaCreateBackup(ctx context.Context, // - The job has failed. The Job will be deleted and recreated to try again. // - The replica creation repo has changed since the Job was created. Delete and recreate // with the Job with the proper repo configured. + // - The "config" annotation has changed, indicating there is a new primary. Delete and + // recreate the Job with the proper config mounted (applicable when a dedicated repo + // host is not enabled). // - The "config hash" annotation has changed, indicating a configuration change has been // made in the spec (specifically a change to the config for an external repo). Delete // and recreate the Job with proper hash per the current config. if failed || replicaCreateRepoChanged || + (job.GetAnnotations()[naming.PGBackRestCurrentConfig] != containerName) || (job.GetAnnotations()[naming.PGBackRestConfigHash] != configHash) { if err := r.Client.Delete(ctx, job, client.PropagationPolicy(metav1.DeletePropagationBackground)); err != nil { @@ -2510,7 +2679,8 @@ func (r *Reconciler) reconcileReplicaCreateBackup(ctx context.Context, // return if no job has been created and the replica repo or the dedicated // repo host is not ready - if job == nil && (!dedicatedRepoReady || !replicaRepoReady) { + if job == nil && ((pgbackrest.RepoHostVolumeDefined(postgresCluster) && !dedicatedRepoReady) || + !replicaRepoReady) { return nil } @@ -2518,7 +2688,7 @@ func (r *Reconciler) reconcileReplicaCreateBackup(ctx context.Context, backupJob := &batchv1.Job{} backupJob.ObjectMeta = naming.PGBackRestBackupJob(postgresCluster) if job != nil { - backupJob.ObjectMeta.Name = job.ObjectMeta.Name + backupJob.Name = job.Name } var labels, annotations map[string]string @@ -2529,20 +2699,20 @@ func (r *Reconciler) reconcileReplicaCreateBackup(ctx context.Context, annotations = naming.Merge(postgresCluster.Spec.Metadata.GetAnnotationsOrNil(), postgresCluster.Spec.Backups.PGBackRest.Metadata.GetAnnotationsOrNil(), map[string]string{ - naming.PGBackRestConfigHash: configHash, + naming.PGBackRestCurrentConfig: containerName, + naming.PGBackRestConfigHash: configHash, }) - backupJob.ObjectMeta.Labels = labels - backupJob.ObjectMeta.Annotations = annotations + backupJob.Labels = labels + backupJob.Annotations = annotations - spec := generateBackupJobSpecIntent(ctx, postgresCluster, replicaCreateRepo, + spec := r.generateBackupJobSpecIntent(ctx, postgresCluster, replicaCreateRepo, serviceAccount.GetName(), labels, annotations) backupJob.Spec = *spec // set gvk and ownership refs backupJob.SetGroupVersionKind(batchv1.SchemeGroupVersion.WithKind("Job")) - if err := controllerutil.SetControllerReference(postgresCluster, backupJob, - r.Client.Scheme()); err != nil { + if err := r.setControllerReference(postgresCluster, backupJob); err != nil { return errors.WithStack(err) } @@ -2574,7 +2744,8 @@ func (r *Reconciler) reconcileRepos(ctx context.Context, if repo.Volume == nil { continue } - repo, err := r.applyRepoVolumeIntent(ctx, postgresCluster, repo.Volume.VolumeClaimSpec, + repo, err := r.applyRepoVolumeIntent(ctx, postgresCluster, + repo.Volume.VolumeClaimSpec.AsPersistentVolumeClaimSpec(), repo.Name, repoResources) if err != nil { log.Error(err, errMsg) @@ -2699,7 +2870,7 @@ func (r *Reconciler) reconcileStanzaCreate(ctx context.Context, } // Don't record event or return an error if configHashMismatch is true, since this just means // configuration changes in ConfigMaps/Secrets have not yet propagated to the container. - // Therefore, just log an an info message and return an error to requeue and try again. + // Therefore, just log an info message and return an error to requeue and try again. if configHashMismatch { return true, nil @@ -2961,7 +3132,7 @@ func (r *Reconciler) reconcilePGBackRestCronJob( // set backup type (i.e. "full", "diff", "incr") backupOpts := []string{"--type=" + backupType} - jobSpec := generateBackupJobSpecIntent(ctx, cluster, repo, + jobSpec := r.generateBackupJobSpecIntent(ctx, cluster, repo, serviceAccount.GetName(), labels, annotations, backupOpts...) // Suspend cronjobs when shutdown or read-only. Any jobs that have already diff --git a/internal/controller/postgrescluster/pgbackrest_test.go b/internal/controller/postgrescluster/pgbackrest_test.go index 8e34dabb5e..6e0cc3a5e6 100644 --- a/internal/controller/postgrescluster/pgbackrest_test.go +++ b/internal/controller/postgrescluster/pgbackrest_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -15,7 +15,6 @@ import ( "testing" "time" - "go.opentelemetry.io/otel" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" @@ -25,9 +24,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/selection" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/rand" @@ -37,11 +34,13 @@ import ( "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/pgbackrest" "github.com/crunchydata/postgres-operator/internal/pki" "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/events" "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -66,7 +65,7 @@ func fakePostgresCluster(clusterName, namespace, clusterUID string, Image: "example.com/crunchy-postgres-ha:test", InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ Name: "instance1", - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ Requests: corev1.ResourceList{ @@ -117,7 +116,7 @@ func fakePostgresCluster(clusterName, namespace, clusterUID string, postgresCluster.Spec.Backups.PGBackRest.Repos[0] = v1beta1.PGBackRestRepo{ Name: "repo1", Volume: &v1beta1.RepoPVC{ - VolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + VolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ Requests: map[corev1.ResourceName]resource.Quantity{ @@ -181,7 +180,6 @@ func TestReconcilePGBackRest(t *testing.T) { r = &Reconciler{ Client: mgr.GetClient(), Recorder: mgr.GetEventRecorderFor(ControllerName), - Tracer: otel.Tracer(ControllerName), Owner: ControllerName, } }) @@ -331,6 +329,8 @@ schedulerName: default-scheduler securityContext: fsGroup: 26 fsGroupChangePolicy: OnRootMismatch +serviceAccount: hippocluster-repohost +serviceAccountName: hippocluster-repohost shareProcessNamespace: true terminationGracePeriodSeconds: 30 tolerations: @@ -464,9 +464,10 @@ topologySpreadConstraints: var instanceConfFound, dedicatedRepoConfFound bool for k, v := range config.Data { if v != "" { - if k == pgbackrest.CMInstanceKey { + switch k { + case pgbackrest.CMInstanceKey: instanceConfFound = true - } else if k == pgbackrest.CMRepoKey { + case pgbackrest.CMRepoKey: dedicatedRepoConfFound = true } } @@ -727,6 +728,48 @@ func TestReconcilePGBackRestRBAC(t *testing.T) { assert.Assert(t, foundSubject) } +func TestReconcileRepoHostRBAC(t *testing.T) { + + ctx := context.Background() + _, tClient := setupKubernetes(t) + require.ParallelCapacity(t, 0) + + r := &Reconciler{Client: tClient, Owner: client.FieldOwner(t.Name())} + + clusterName := "hippocluster" + clusterUID := "hippouid" + + ns := setupNamespace(t, tClient) + + // create a PostgresCluster to test with + postgresCluster := fakePostgresCluster(clusterName, ns.GetName(), clusterUID, true) + // create an example AWS ARN annotation + annotations := map[string]string{ + "eks.amazonaws.com/role-arn": "arn:aws:iam::123456768901:role/allow_bucket_access", + } + // set the annotation on the cluster + postgresCluster.Spec.Metadata = &v1beta1.Metadata{ + Annotations: annotations, + } + + postgresCluster.Status.PGBackRest = &v1beta1.PGBackRestStatus{ + Repos: []v1beta1.RepoStatus{{Name: "repo1", StanzaCreated: false}}, + } + + serviceAccount, err := r.reconcileRepoHostRBAC(ctx, postgresCluster) + assert.NilError(t, err) + assert.Assert(t, serviceAccount != nil) + + // verify the service account has been created + sa := &corev1.ServiceAccount{} + err = tClient.Get(ctx, types.NamespacedName{ + Name: naming.RepoHostRBAC(postgresCluster).Name, + Namespace: postgresCluster.GetNamespace(), + }, sa) + assert.NilError(t, err) + assert.DeepEqual(t, sa.Annotations, annotations) +} + func TestReconcileStanzaCreate(t *testing.T) { cfg, tClient := setupKubernetes(t) require.ParallelCapacity(t, 0) @@ -736,7 +779,6 @@ func TestReconcileStanzaCreate(t *testing.T) { r = &Reconciler{ Client: mgr.GetClient(), Recorder: mgr.GetEventRecorderFor(ControllerName), - Tracer: otel.Tracer(ControllerName), Owner: ControllerName, } }) @@ -755,7 +797,7 @@ func TestReconcileStanzaCreate(t *testing.T) { instances := newObservedInstances(postgresCluster, nil, []corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{"status": `"role":"master"`}, + Annotations: map[string]string{"status": `"role":"primary"`}, Labels: map[string]string{ naming.LabelCluster: postgresCluster.GetName(), naming.LabelInstance: "", @@ -871,7 +913,7 @@ func TestReconcileReplicaCreateBackup(t *testing.T) { } instances := newObservedInstances(postgresCluster, nil, []corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string{"status": `"role":"master"`}, + Annotations: map[string]string{"status": `"role":"primary"`}, Labels: map[string]string{ naming.LabelCluster: postgresCluster.GetName(), naming.LabelInstance: "", @@ -922,7 +964,7 @@ func TestReconcileReplicaCreateBackup(t *testing.T) { var foundOwnershipRef bool // verify ownership refs - for _, ref := range backupJob.ObjectMeta.GetOwnerReferences() { + for _, ref := range backupJob.GetOwnerReferences() { if ref.Name == clusterName { foundOwnershipRef = true break @@ -930,13 +972,17 @@ func TestReconcileReplicaCreateBackup(t *testing.T) { } assert.Assert(t, foundOwnershipRef) - var foundHashAnnotation bool + var foundConfigAnnotation, foundHashAnnotation bool // verify annotations for k, v := range backupJob.GetAnnotations() { + if k == naming.PGBackRestCurrentConfig && v == naming.PGBackRestRepoContainerName { + foundConfigAnnotation = true + } if k == naming.PGBackRestConfigHash && v == configHash { foundHashAnnotation = true } } + assert.Assert(t, foundConfigAnnotation) assert.Assert(t, foundHashAnnotation) // verify container & env vars @@ -1015,7 +1061,6 @@ func TestReconcileManualBackup(t *testing.T) { r = &Reconciler{ Client: mgr.GetClient(), Recorder: mgr.GetEventRecorderFor(ControllerName), - Tracer: otel.Tracer(ControllerName), Owner: ControllerName, } }) @@ -1353,7 +1398,7 @@ func TestReconcileManualBackup(t *testing.T) { instances.forCluster[0].Pods[0].Annotations = map[string]string{} } else { instances.forCluster[0].Pods[0].Annotations = map[string]string{ - "status": `"role":"master"`, + "status": `"role":"primary"`, } } @@ -1658,11 +1703,47 @@ func TestGetPGBackRestResources(t *testing.T) { jobCount: 0, pvcCount: 0, hostCount: 1, }, }, { - desc: "no dedicated repo host defined, dedicated sts not deleted", + desc: "no dedicated repo host defined delete dedicated sts", + createResources: []client.Object{ + &appsv1.StatefulSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "delete-dedicated", + Namespace: namespace, + Labels: naming.PGBackRestDedicatedLabels(clusterName), + }, + Spec: appsv1.StatefulSetSpec{ + Selector: metav1.SetAsLabelSelector( + naming.PGBackRestDedicatedLabels(clusterName)), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: naming.PGBackRestDedicatedLabels(clusterName), + }, + Spec: corev1.PodSpec{}, + }, + }, + }, + }, + cluster: &v1beta1.PostgresCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterName, + Namespace: namespace, + UID: types.UID(clusterUID), + }, + Spec: v1beta1.PostgresClusterSpec{ + Backups: v1beta1.Backups{ + PGBackRest: v1beta1.PGBackRestArchive{}, + }, + }, + }, + result: testResult{ + jobCount: 0, pvcCount: 0, hostCount: 0, + }, + }, { + desc: "no repo host defined delete dedicated sts", createResources: []client.Object{ &appsv1.StatefulSet{ ObjectMeta: metav1.ObjectMeta{ - Name: "keep-dedicated-two", + Name: "delete-dedicated-no-repo-host", Namespace: namespace, Labels: naming.PGBackRestDedicatedLabels(clusterName), }, @@ -1691,8 +1772,7 @@ func TestGetPGBackRestResources(t *testing.T) { }, }, result: testResult{ - // Host count is 2 due to previous repo host sts not being deleted. - jobCount: 0, pvcCount: 0, hostCount: 2, + jobCount: 0, pvcCount: 0, hostCount: 0, }, }} @@ -1725,7 +1805,6 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { r = &Reconciler{ Client: tClient, Recorder: mgr.GetEventRecorderFor(ControllerName), - Tracer: otel.Tracer(ControllerName), Owner: ControllerName, } }) @@ -1739,6 +1818,9 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { configCount, jobCount, pvcCount int invalidSourceRepo, invalidSourceCluster, invalidOptions bool expectedClusterCondition *metav1.Condition + expectedEventMessage string + expectedCommandPieces []string + missingCommandPieces []string } for _, dedicated := range []bool{true, false} { @@ -1761,6 +1843,8 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { configCount: 1, jobCount: 1, pvcCount: 1, invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: false, expectedClusterCondition: nil, + expectedCommandPieces: []string{"--stanza=", "--pg1-path=", "--repo=", "--delta"}, + missingCommandPieces: []string{"--target-action"}, }, }, { desc: "invalid source cluster", @@ -1774,6 +1858,7 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { configCount: 0, jobCount: 0, pvcCount: 0, invalidSourceRepo: false, invalidSourceCluster: true, invalidOptions: false, expectedClusterCondition: nil, + expectedEventMessage: "does not exist", }, }, { desc: "invalid source repo", @@ -1787,6 +1872,7 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { configCount: 1, jobCount: 0, pvcCount: 0, invalidSourceRepo: true, invalidSourceCluster: false, invalidOptions: false, expectedClusterCondition: nil, + expectedEventMessage: "does not have a repo named", }, }, { desc: "invalid option: --repo=", @@ -1801,6 +1887,7 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { configCount: 1, jobCount: 0, pvcCount: 1, invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: true, expectedClusterCondition: nil, + expectedEventMessage: "Option '--repo' is not allowed: please use the 'repoName' field instead.", }, }, { desc: "invalid option: --repo ", @@ -1815,6 +1902,7 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { configCount: 1, jobCount: 0, pvcCount: 1, invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: true, expectedClusterCondition: nil, + expectedEventMessage: "Option '--repo' is not allowed: please use the 'repoName' field instead.", }, }, { desc: "invalid option: stanza", @@ -1829,6 +1917,7 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { configCount: 1, jobCount: 0, pvcCount: 1, invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: true, expectedClusterCondition: nil, + expectedEventMessage: "Option '--stanza' is not allowed: the operator will automatically set this option", }, }, { desc: "invalid option: pg1-path", @@ -1843,6 +1932,68 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { configCount: 1, jobCount: 0, pvcCount: 1, invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: true, expectedClusterCondition: nil, + expectedEventMessage: "Option '--pg1-path' is not allowed: the operator will automatically set this option", + }, + }, { + desc: "invalid option: target-action", + dataSource: &v1beta1.DataSource{PostgresCluster: &v1beta1.PostgresClusterDataSource{ + ClusterName: "invalid-target-action-option", RepoName: "repo1", + Options: []string{"--target-action"}, + }}, + clusterBootstrapped: false, + sourceClusterName: "invalid-target-action-option", + sourceClusterRepos: []v1beta1.PGBackRestRepo{{Name: "repo1"}}, + result: testResult{ + configCount: 1, jobCount: 0, pvcCount: 1, + invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: true, + expectedClusterCondition: nil, + expectedEventMessage: "Option '--target-action' is not allowed: the operator will automatically set this option", + }, + }, { + desc: "invalid option: link-map", + dataSource: &v1beta1.DataSource{PostgresCluster: &v1beta1.PostgresClusterDataSource{ + ClusterName: "invalid-link-map-option", RepoName: "repo1", + Options: []string{"--link-map"}, + }}, + clusterBootstrapped: false, + sourceClusterName: "invalid-link-map-option", + sourceClusterRepos: []v1beta1.PGBackRestRepo{{Name: "repo1"}}, + result: testResult{ + configCount: 1, jobCount: 0, pvcCount: 1, + invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: true, + expectedClusterCondition: nil, + expectedEventMessage: "Option '--link-map' is not allowed: the operator will automatically set this option", + }, + }, { + desc: "valid option: target-timeline", + dataSource: &v1beta1.DataSource{PostgresCluster: &v1beta1.PostgresClusterDataSource{ + ClusterName: "valid-target-timeline-option", RepoName: "repo1", + Options: []string{"--target-timeline=1"}, + }}, + clusterBootstrapped: false, + sourceClusterName: "valid-target-timeline-option", + sourceClusterRepos: []v1beta1.PGBackRestRepo{{Name: "repo1"}}, + result: testResult{ + configCount: 1, jobCount: 1, pvcCount: 1, + invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: false, + expectedClusterCondition: nil, + expectedCommandPieces: []string{"--stanza=", "--pg1-path=", "--repo=", "--delta", "--target-timeline=1"}, + missingCommandPieces: []string{"--target=", "--target-action=promote"}, + }, + }, { + desc: "valid option: target", + dataSource: &v1beta1.DataSource{PostgresCluster: &v1beta1.PostgresClusterDataSource{ + ClusterName: "valid-target-option", RepoName: "repo1", + Options: []string{"--target=some-date"}, + }}, + clusterBootstrapped: false, + sourceClusterName: "valid-target-option", + sourceClusterRepos: []v1beta1.PGBackRestRepo{{Name: "repo1"}}, + result: testResult{ + configCount: 1, jobCount: 1, pvcCount: 1, + invalidSourceRepo: false, invalidSourceCluster: false, invalidOptions: false, + expectedClusterCondition: nil, + expectedCommandPieces: []string{"--stanza=", "--pg1-path=", "--repo=", "--delta", "--target=some-date", "--target-action=promote"}, }, }, { desc: "cluster bootstrapped init condition missing", @@ -1965,6 +2116,16 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { if len(restoreJobs.Items) == 1 { assert.Assert(t, restoreJobs.Items[0].Labels[naming.LabelStartupInstance] != "") assert.Assert(t, restoreJobs.Items[0].Annotations[naming.PGBackRestConfigHash] != "") + for _, cmd := range tc.result.expectedCommandPieces { + assert.Assert(t, cmp.Contains( + strings.Join(restoreJobs.Items[0].Spec.Template.Spec.Containers[0].Command, " "), + cmd)) + } + for _, cmd := range tc.result.missingCommandPieces { + assert.Assert(t, !strings.Contains( + strings.Join(restoreJobs.Items[0].Spec.Template.Spec.Containers[0].Command, " "), + cmd)) + } } dataPVCs := &corev1.PersistentVolumeClaimList{} @@ -2002,7 +2163,11 @@ func TestReconcilePostgresClusterDataSource(t *testing.T) { "involvedObject.namespace": namespace, "reason": "InvalidDataSource", }) - return len(events.Items) == 1, err + eventExists := len(events.Items) > 0 + if eventExists { + assert.Assert(t, cmp.Contains(events.Items[0].Message, tc.result.expectedEventMessage)) + } + return eventExists, err })) } }) @@ -2019,7 +2184,6 @@ func TestReconcileCloudBasedDataSource(t *testing.T) { r = &Reconciler{ Client: tClient, Recorder: mgr.GetEventRecorderFor(ControllerName), - Tracer: otel.Tracer(ControllerName), Owner: ControllerName, } }) @@ -2231,7 +2395,7 @@ func TestCopyConfigurationResources(t *testing.T) { Image: "example.com/crunchy-postgres-ha:test", InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ Name: "instance1", - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ Requests: corev1.ResourceList{ @@ -2283,7 +2447,7 @@ func TestCopyConfigurationResources(t *testing.T) { }, InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ Name: "instance1", - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ Requests: corev1.ResourceList{ @@ -2438,10 +2602,90 @@ func TestCopyConfigurationResources(t *testing.T) { } func TestGenerateBackupJobIntent(t *testing.T) { + _, cc := setupKubernetes(t) + require.ParallelCapacity(t, 0) + ns := setupNamespace(t, cc) + + r := &Reconciler{ + Client: cc, + Owner: ControllerName, + } + ctx := context.Background() + cluster := v1beta1.PostgresCluster{} + cluster.Name = "hippo-test" + cluster.Default() + + // If repo.Volume is nil, the code interprets this as a cloud repo backup, + // therefore, an "empty" input results in a job spec for a cloud repo backup t.Run("empty", func(t *testing.T) { - spec := generateBackupJobSpecIntent(ctx, - &v1beta1.PostgresCluster{}, v1beta1.PGBackRestRepo{}, + spec := r.generateBackupJobSpecIntent(ctx, + &cluster, v1beta1.PGBackRestRepo{}, + "", + nil, nil, + ) + assert.Assert(t, cmp.MarshalMatches(spec.Template.Spec, ` +containers: +- command: + - /bin/pgbackrest + - backup + - --stanza=db + - --repo= + name: pgbackrest + resources: {} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + privileged: false + readOnlyRootFilesystem: true + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumeMounts: + - mountPath: /etc/pgbackrest/conf.d + name: pgbackrest-config + readOnly: true + - mountPath: /tmp + name: tmp +enableServiceLinks: false +restartPolicy: Never +securityContext: + fsGroup: 26 + fsGroupChangePolicy: OnRootMismatch +volumes: +- name: pgbackrest-config + projected: + sources: + - configMap: + items: + - key: pgbackrest_cloud.conf + path: pgbackrest_cloud.conf + name: hippo-test-pgbackrest-config + - secret: + items: + - key: pgbackrest.ca-roots + path: ~postgres-operator/tls-ca.crt + - key: pgbackrest-client.crt + path: ~postgres-operator/client-tls.crt + - key: pgbackrest-client.key + mode: 384 + path: ~postgres-operator/client-tls.key + name: hippo-test-pgbackrest +- emptyDir: + sizeLimit: 16Mi + name: tmp + `)) + }) + + t.Run("volumeRepo", func(t *testing.T) { + spec := r.generateBackupJobSpecIntent(ctx, + &cluster, v1beta1.PGBackRestRepo{ + Volume: &v1beta1.RepoPVC{ + VolumeClaimSpec: v1beta1.VolumeClaimSpec{}, + }, + }, "", nil, nil, ) @@ -2460,7 +2704,7 @@ containers: value: pgbackrest - name: NAMESPACE - name: SELECTOR - value: postgres-operator.crunchydata.com/cluster=,postgres-operator.crunchydata.com/pgbackrest=,postgres-operator.crunchydata.com/pgbackrest-dedicated= + value: postgres-operator.crunchydata.com/cluster=hippo-test,postgres-operator.crunchydata.com/pgbackrest=,postgres-operator.crunchydata.com/pgbackrest-dedicated= name: pgbackrest resources: {} securityContext: @@ -2493,7 +2737,7 @@ volumes: path: config-hash - key: pgbackrest-server.conf path: ~postgres-operator_server.conf - name: -pgbackrest-config + name: hippo-test-pgbackrest-config - secret: items: - key: pgbackrest.ca-roots @@ -2503,7 +2747,7 @@ volumes: - key: pgbackrest-client.key mode: 384 path: ~postgres-operator/client-tls.key - name: -pgbackrest + name: hippo-test-pgbackrest `)) }) @@ -2513,7 +2757,7 @@ volumes: ImagePullPolicy: corev1.PullAlways, }, } - job := generateBackupJobSpecIntent(ctx, + job := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, @@ -2528,7 +2772,7 @@ volumes: cluster.Spec.Backups = v1beta1.Backups{ PGBackRest: v1beta1.PGBackRestArchive{}, } - job := generateBackupJobSpecIntent(ctx, + job := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, @@ -2545,7 +2789,7 @@ volumes: }, }, } - job := generateBackupJobSpecIntent(ctx, + job := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, @@ -2584,7 +2828,7 @@ volumes: }, }, } - job := generateBackupJobSpecIntent(ctx, + job := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, @@ -2597,7 +2841,7 @@ volumes: cluster.Spec.Backups.PGBackRest.Jobs = &v1beta1.BackupJobs{ PriorityClassName: initialize.String("some-priority-class"), } - job := generateBackupJobSpecIntent(ctx, + job := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, @@ -2615,7 +2859,7 @@ volumes: cluster.Spec.Backups.PGBackRest.Jobs = &v1beta1.BackupJobs{ Tolerations: tolerations, } - job := generateBackupJobSpecIntent(ctx, + job := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, @@ -2629,14 +2873,14 @@ volumes: t.Run("Undefined", func(t *testing.T) { cluster.Spec.Backups.PGBackRest.Jobs = nil - spec := generateBackupJobSpecIntent(ctx, + spec := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, ) assert.Assert(t, spec.TTLSecondsAfterFinished == nil) cluster.Spec.Backups.PGBackRest.Jobs = &v1beta1.BackupJobs{} - spec = generateBackupJobSpecIntent(ctx, + spec = r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, ) assert.Assert(t, spec.TTLSecondsAfterFinished == nil) @@ -2647,7 +2891,7 @@ volumes: TTLSecondsAfterFinished: initialize.Int32(0), } - spec := generateBackupJobSpecIntent(ctx, + spec := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, ) if assert.Check(t, spec.TTLSecondsAfterFinished != nil) { @@ -2660,7 +2904,7 @@ volumes: TTLSecondsAfterFinished: initialize.Int32(100), } - spec := generateBackupJobSpecIntent(ctx, + spec := r.generateBackupJobSpecIntent(ctx, cluster, v1beta1.PGBackRestRepo{}, "", nil, nil, ) if assert.Check(t, spec.TTLSecondsAfterFinished != nil) { @@ -2668,6 +2912,204 @@ volumes: } }) }) + + t.Run("CloudLogVolumeAnnotationNoPvc", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + r.Recorder = recorder + + cluster.Namespace = ns.Name + cluster.Annotations = map[string]string{} + cluster.Annotations[naming.PGBackRestCloudLogVolume] = "some-pvc" + spec := r.generateBackupJobSpecIntent(ctx, + &cluster, v1beta1.PGBackRestRepo{}, + "", + nil, nil, + ) + assert.Assert(t, cmp.MarshalMatches(spec.Template.Spec, ` +containers: +- command: + - /bin/pgbackrest + - backup + - --stanza=db + - --repo= + name: pgbackrest + resources: {} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + privileged: false + readOnlyRootFilesystem: true + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumeMounts: + - mountPath: /etc/pgbackrest/conf.d + name: pgbackrest-config + readOnly: true + - mountPath: /tmp + name: tmp +enableServiceLinks: false +restartPolicy: Never +securityContext: + fsGroup: 26 + fsGroupChangePolicy: OnRootMismatch +volumes: +- name: pgbackrest-config + projected: + sources: + - configMap: + items: + - key: pgbackrest_cloud.conf + path: pgbackrest_cloud.conf + name: hippo-test-pgbackrest-config + - secret: + items: + - key: pgbackrest.ca-roots + path: ~postgres-operator/tls-ca.crt + - key: pgbackrest-client.crt + path: ~postgres-operator/client-tls.crt + - key: pgbackrest-client.key + mode: 384 + path: ~postgres-operator/client-tls.key + name: hippo-test-pgbackrest +- emptyDir: + sizeLimit: 16Mi + name: tmp + `)) + + assert.Equal(t, len(recorder.Events), 1) + assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) + assert.Equal(t, recorder.Events[0].Reason, "PGBackRestCloudLogVolumeNotFound") + assert.Equal(t, recorder.Events[0].Note, "persistentvolumeclaims \"some-pvc\" not found") + }) + + t.Run("CloudLogVolumeAnnotationPvcInPlace", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + r.Recorder = recorder + + cluster.Namespace = ns.Name + cluster.Annotations = map[string]string{} + cluster.Annotations[naming.PGBackRestCloudLogVolume] = "another-pvc" + + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "another-pvc", + Namespace: ns.Name, + }, + Spec: corev1.PersistentVolumeClaimSpec(testVolumeClaimSpec()), + } + err := r.Client.Create(ctx, pvc) + assert.NilError(t, err) + + spec := r.generateBackupJobSpecIntent(ctx, + &cluster, v1beta1.PGBackRestRepo{}, + "", + nil, nil, + ) + assert.Assert(t, cmp.MarshalMatches(spec.Template.Spec, ` +containers: +- command: + - /bin/pgbackrest + - backup + - --stanza=db + - --repo= + name: pgbackrest + resources: {} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + privileged: false + readOnlyRootFilesystem: true + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + volumeMounts: + - mountPath: /etc/pgbackrest/conf.d + name: pgbackrest-config + readOnly: true + - mountPath: /tmp + name: tmp + - mountPath: /volumes/another-pvc + name: another-pvc +enableServiceLinks: false +restartPolicy: Never +securityContext: + fsGroup: 26 + fsGroupChangePolicy: OnRootMismatch +volumes: +- name: pgbackrest-config + projected: + sources: + - configMap: + items: + - key: pgbackrest_cloud.conf + path: pgbackrest_cloud.conf + name: hippo-test-pgbackrest-config + - secret: + items: + - key: pgbackrest.ca-roots + path: ~postgres-operator/tls-ca.crt + - key: pgbackrest-client.crt + path: ~postgres-operator/client-tls.crt + - key: pgbackrest-client.key + mode: 384 + path: ~postgres-operator/client-tls.key + name: hippo-test-pgbackrest +- emptyDir: + sizeLimit: 16Mi + name: tmp +- name: another-pvc + persistentVolumeClaim: + claimName: another-pvc + `)) + + // No events created + assert.Equal(t, len(recorder.Events), 0) + }) + + t.Run("AdditionalVolumes", func(t *testing.T) { + recorder := events.NewRecorder(t, runtime.Scheme) + r.Recorder = recorder + + cluster := cluster.DeepCopy() + cluster.Namespace = ns.Name + cluster.Spec.Backups.PGBackRest.Jobs = &v1beta1.BackupJobs{ + Volumes: &v1beta1.PGBackRestVolumesSpec{ + Additional: []v1beta1.AdditionalVolume{ + { + ClaimName: "additional-pvc", + Name: "stuff", + }, + }, + }, + } + + spec := r.generateBackupJobSpecIntent(ctx, + cluster, v1beta1.PGBackRestRepo{}, + "", + nil, nil, + ) + + for _, container := range spec.Template.Spec.Containers { + assert.Assert(t, cmp.MarshalContains(container.VolumeMounts, + ` +- mountPath: /volumes/stuff + name: volumes-stuff`)) + } + + assert.Assert(t, cmp.MarshalContains(spec.Template.Spec.Volumes, + ` +- name: volumes-stuff + persistentVolumeClaim: + claimName: additional-pvc`)) + + // No events created + assert.Equal(t, len(recorder.Events), 0) + }) } func TestGenerateRepoHostIntent(t *testing.T) { @@ -2679,12 +3121,12 @@ func TestGenerateRepoHostIntent(t *testing.T) { t.Run("empty", func(t *testing.T) { _, err := r.generateRepoHostIntent(ctx, &v1beta1.PostgresCluster{}, "", &RepoResources{}, - &observedInstances{}) + &observedInstances{}, "") assert.NilError(t, err) }) cluster := &v1beta1.PostgresCluster{} - sts, err := r.generateRepoHostIntent(ctx, cluster, "", &RepoResources{}, &observedInstances{}) + sts, err := r.generateRepoHostIntent(ctx, cluster, "", &RepoResources{}, &observedInstances{}, "") assert.NilError(t, err) t.Run("ServiceAccount", func(t *testing.T) { @@ -2705,7 +3147,7 @@ func TestGenerateRepoHostIntent(t *testing.T) { }, } observed := &observedInstances{forCluster: []*Instance{{Pods: []*corev1.Pod{{}}}}} - sts, err := r.generateRepoHostIntent(ctx, cluster, "", &RepoResources{}, observed) + sts, err := r.generateRepoHostIntent(ctx, cluster, "", &RepoResources{}, observed, "") assert.NilError(t, err) assert.Equal(t, *sts.Spec.Replicas, int32(1)) }) @@ -2717,10 +3159,47 @@ func TestGenerateRepoHostIntent(t *testing.T) { }, } observed := &observedInstances{forCluster: []*Instance{{}}} - sts, err := r.generateRepoHostIntent(ctx, cluster, "", &RepoResources{}, observed) + sts, err := r.generateRepoHostIntent(ctx, cluster, "", &RepoResources{}, observed, "") assert.NilError(t, err) assert.Equal(t, *sts.Spec.Replicas, int32(0)) }) + + t.Run("AdditionalVolumes", func(t *testing.T) { + cluster := &v1beta1.PostgresCluster{ + Spec: v1beta1.PostgresClusterSpec{ + Backups: v1beta1.Backups{ + PGBackRest: v1beta1.PGBackRestArchive{ + RepoHost: &v1beta1.PGBackRestRepoHost{ + Volumes: &v1beta1.PGBackRestVolumesSpec{ + Additional: []v1beta1.AdditionalVolume{ + { + ClaimName: "additional-pvc", + Name: "stuff", + }, + }, + }, + }, + }, + }, + }, + } + observed := &observedInstances{forCluster: []*Instance{{}}} + sts, err := r.generateRepoHostIntent(ctx, cluster, "", &RepoResources{}, observed, "") + assert.NilError(t, err) + + for _, container := range sts.Spec.Template.Spec.Containers { + assert.Assert(t, cmp.MarshalContains(container.VolumeMounts, + ` +- mountPath: /volumes/stuff + name: volumes-stuff`)) + } + + assert.Assert(t, cmp.MarshalContains(sts.Spec.Template.Spec.Volumes, + ` +- name: volumes-stuff + persistentVolumeClaim: + claimName: additional-pvc`)) + }) } func TestGenerateRestoreJobIntent(t *testing.T) { @@ -2772,6 +3251,14 @@ func TestGenerateRestoreJobIntent(t *testing.T) { Operator: "Exist", }}, PriorityClassName: initialize.String("some-priority-class"), + Volumes: &v1beta1.PGBackRestVolumesSpec{ + Additional: []v1beta1.AdditionalVolume{ + { + ClaimName: "additional-pvc", + Name: "stuff", + }, + }, + }, } cluster := &v1beta1.PostgresCluster{ ObjectMeta: metav1.ObjectMeta{ @@ -2805,11 +3292,11 @@ func TestGenerateRestoreJobIntent(t *testing.T) { t.Run(fmt.Sprintf("openshift-%v", openshift), func(t *testing.T) { t.Run("ObjectMeta", func(t *testing.T) { t.Run("Name", func(t *testing.T) { - assert.Equal(t, job.ObjectMeta.Name, + assert.Equal(t, job.Name, naming.PGBackRestRestoreJob(cluster).Name) }) t.Run("Namespace", func(t *testing.T) { - assert.Equal(t, job.ObjectMeta.Namespace, + assert.Equal(t, job.Namespace, naming.PGBackRestRestoreJob(cluster).Namespace) }) t.Run("Annotations", func(t *testing.T) { @@ -2864,6 +3351,9 @@ func TestGenerateRestoreJobIntent(t *testing.T) { assert.DeepEqual(t, job.Spec.Template.Spec.Containers[0].VolumeMounts, []corev1.VolumeMount{{ Name: "mount", + }, { + Name: "volumes-stuff", + MountPath: "/volumes/stuff", }}) }) t.Run("Env", func(t *testing.T) { @@ -2887,6 +3377,13 @@ func TestGenerateRestoreJobIntent(t *testing.T) { assert.DeepEqual(t, job.Spec.Template.Spec.Volumes, []corev1.Volume{{ Name: "volume", + }, { + Name: "volumes-stuff", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: "additional-pvc", + }, + }, }}) }) t.Run("Affinity", func(t *testing.T) { @@ -2965,14 +3462,23 @@ func TestObserveRestoreEnv(t *testing.T) { }, } + currentTime := metav1.Now() + startTime := metav1.NewTime(currentTime.AddDate(0, 0, -1)) + restoreJob.Status.StartTime = &startTime + if completed != nil { if *completed { + restoreJob.Status.CompletionTime = ¤tTime restoreJob.Status.Conditions = append(restoreJob.Status.Conditions, batchv1.JobCondition{ Type: batchv1.JobComplete, Status: corev1.ConditionTrue, Reason: "test", Message: "test", - }) + }, + batchv1.JobCondition{ + Type: batchv1.JobSuccessCriteriaMet, + Status: corev1.ConditionTrue, + }) } else { restoreJob.Status.Conditions = append(restoreJob.Status.Conditions, batchv1.JobCondition{ Type: batchv1.JobComplete, @@ -2988,7 +3494,12 @@ func TestObserveRestoreEnv(t *testing.T) { Status: corev1.ConditionTrue, Reason: "test", Message: "test", - }) + }, + batchv1.JobCondition{ + Type: batchv1.JobFailureTarget, + Status: corev1.ConditionTrue, + }, + ) } else { restoreJob.Status.Conditions = append(restoreJob.Status.Conditions, batchv1.JobCondition{ Type: batchv1.JobFailed, @@ -3018,15 +3529,15 @@ func TestObserveRestoreEnv(t *testing.T) { createResources: func(t *testing.T, cluster *v1beta1.PostgresCluster) { fakeLeaderEP := &corev1.Endpoints{} fakeLeaderEP.ObjectMeta = naming.PatroniLeaderEndpoints(cluster) - fakeLeaderEP.ObjectMeta.Namespace = namespace + fakeLeaderEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, fakeLeaderEP)) fakeDCSEP := &corev1.Endpoints{} fakeDCSEP.ObjectMeta = naming.PatroniDistributedConfiguration(cluster) - fakeDCSEP.ObjectMeta.Namespace = namespace + fakeDCSEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, fakeDCSEP)) fakeFailoverEP := &corev1.Endpoints{} fakeFailoverEP.ObjectMeta = naming.PatroniTrigger(cluster) - fakeFailoverEP.ObjectMeta.Namespace = namespace + fakeFailoverEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, fakeFailoverEP)) job := generateJob(cluster.Name, initialize.Bool(false), initialize.Bool(false)) @@ -3042,15 +3553,15 @@ func TestObserveRestoreEnv(t *testing.T) { createResources: func(t *testing.T, cluster *v1beta1.PostgresCluster) { fakeLeaderEP := &corev1.Endpoints{} fakeLeaderEP.ObjectMeta = naming.PatroniLeaderEndpoints(cluster) - fakeLeaderEP.ObjectMeta.Namespace = namespace + fakeLeaderEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, fakeLeaderEP)) fakeDCSEP := &corev1.Endpoints{} fakeDCSEP.ObjectMeta = naming.PatroniDistributedConfiguration(cluster) - fakeDCSEP.ObjectMeta.Namespace = namespace + fakeDCSEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, fakeDCSEP)) fakeFailoverEP := &corev1.Endpoints{} fakeFailoverEP.ObjectMeta = naming.PatroniTrigger(cluster) - fakeFailoverEP.ObjectMeta.Namespace = namespace + fakeFailoverEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, fakeFailoverEP)) }, result: testResult{ @@ -3220,15 +3731,15 @@ func TestPrepareForRestore(t *testing.T) { cluster *v1beta1.PostgresCluster) (*batchv1.Job, []corev1.Endpoints) { fakeLeaderEP := corev1.Endpoints{} fakeLeaderEP.ObjectMeta = naming.PatroniLeaderEndpoints(cluster) - fakeLeaderEP.ObjectMeta.Namespace = namespace + fakeLeaderEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, &fakeLeaderEP)) fakeDCSEP := corev1.Endpoints{} fakeDCSEP.ObjectMeta = naming.PatroniDistributedConfiguration(cluster) - fakeDCSEP.ObjectMeta.Namespace = namespace + fakeDCSEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, &fakeDCSEP)) fakeFailoverEP := corev1.Endpoints{} fakeFailoverEP.ObjectMeta = naming.PatroniTrigger(cluster) - fakeFailoverEP.ObjectMeta.Namespace = namespace + fakeFailoverEP.Namespace = namespace assert.NilError(t, r.Client.Create(ctx, &fakeFailoverEP)) return nil, []corev1.Endpoints{fakeLeaderEP, fakeDCSEP, fakeFailoverEP} }, @@ -3394,7 +3905,6 @@ func TestReconcileScheduledBackups(t *testing.T) { r = &Reconciler{ Client: mgr.GetClient(), Recorder: mgr.GetEventRecorderFor(ControllerName), - Tracer: otel.Tracer(ControllerName), Owner: ControllerName, } }) @@ -3667,7 +4177,7 @@ func TestSetScheduledJobStatus(t *testing.T) { // create a PostgresCluster to test with postgresCluster := fakePostgresCluster(clusterName, ns.GetName(), clusterUID, true) - testJob := &batchv1.Job{ + uList, err := runtime.ToUnstructuredList(&batchv1.JobList{Items: []batchv1.Job{{ TypeMeta: metav1.TypeMeta{ Kind: "Job", }, @@ -3680,18 +4190,8 @@ func TestSetScheduledJobStatus(t *testing.T) { Succeeded: 2, Failed: 3, }, - } - - // convert the runtime.Object to an unstructured object - unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(testJob) + }}}) assert.NilError(t, err) - unstructuredJob := &unstructured.Unstructured{ - Object: unstructuredObj, - } - - // add it to an unstructured list - uList := &unstructured.UnstructuredList{} - uList.Items = append(uList.Items, *unstructuredJob) // set the status r.setScheduledJobStatus(ctx, postgresCluster, uList.Items) @@ -3706,7 +4206,7 @@ func TestSetScheduledJobStatus(t *testing.T) { // create a PostgresCluster to test with postgresCluster := fakePostgresCluster(clusterName, ns.GetName(), clusterUID, true) - testJob := &batchv1.Job{ + uList, err := runtime.ToUnstructuredList(&batchv1.JobList{Items: []batchv1.Job{{ TypeMeta: metav1.TypeMeta{ Kind: "Job", }, @@ -3718,18 +4218,8 @@ func TestSetScheduledJobStatus(t *testing.T) { Succeeded: 2, Failed: 3, }, - } - - // convert the runtime.Object to an unstructured object - unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(testJob) + }}}) assert.NilError(t, err) - unstructuredJob := &unstructured.Unstructured{ - Object: unstructuredObj, - } - - // add it to an unstructured list - uList := &unstructured.UnstructuredList{} - uList.Items = append(uList.Items, *unstructuredJob) // set the status r.setScheduledJobStatus(ctx, postgresCluster, uList.Items) @@ -3751,7 +4241,6 @@ func TestBackupsEnabled(t *testing.T) { r = &Reconciler{ Client: mgr.GetClient(), Recorder: mgr.GetEventRecorderFor(ControllerName), - Tracer: otel.Tracer(ControllerName), Owner: ControllerName, } }) diff --git a/internal/controller/postgrescluster/pgbouncer.go b/internal/controller/postgrescluster/pgbouncer.go index 76207fac02..822f69f57d 100644 --- a/internal/controller/postgrescluster/pgbouncer.go +++ b/internal/controller/postgrescluster/pgbouncer.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -18,6 +18,7 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/naming" @@ -40,10 +41,11 @@ func (r *Reconciler) reconcilePGBouncer( service, err := r.reconcilePGBouncerService(ctx, cluster) if err == nil { - configmap, err = r.reconcilePGBouncerConfigMap(ctx, cluster) + secret, err = r.reconcilePGBouncerSecret(ctx, cluster, root, service) } if err == nil { - secret, err = r.reconcilePGBouncerSecret(ctx, cluster, root, service) + config := collector.NewConfigForPgBouncerPod(ctx, cluster, pgbouncer.PostgresqlUser) + configmap, err = r.reconcilePGBouncerConfigMap(ctx, cluster, config) } if err == nil { err = r.reconcilePGBouncerDeployment(ctx, cluster, primaryCertificate, configmap, secret) @@ -63,6 +65,7 @@ func (r *Reconciler) reconcilePGBouncer( // reconcilePGBouncerConfigMap writes the ConfigMap for a PgBouncer Pod. func (r *Reconciler) reconcilePGBouncerConfigMap( ctx context.Context, cluster *v1beta1.PostgresCluster, + otelConfig *collector.Config, ) (*corev1.ConfigMap, error) { configmap := &corev1.ConfigMap{ObjectMeta: naming.ClusterPGBouncer(cluster)} configmap.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) @@ -92,7 +95,20 @@ func (r *Reconciler) reconcilePGBouncerConfigMap( }) if err == nil { - pgbouncer.ConfigMap(cluster, configmap) + pgbouncer.ConfigMap(ctx, cluster, configmap) + } + // If OTel logging or metrics is enabled, add collector config + if collector.OpenTelemetryLogsOrMetricsEnabled(ctx, cluster) { + err = collector.AddToConfigMap(ctx, otelConfig, configmap) + } + // If OTel logging is enabled, add logrotate config + if err == nil && collector.OpenTelemetryLogsEnabled(ctx, cluster) { + logrotateConfig := collector.LogrotateConfig{ + LogFiles: []string{naming.PGBouncerFullLogPath}, + PostrotateScript: collector.PGBouncerPostRotateScript, + } + collector.AddLogrotateConfigs(ctx, cluster.Spec.Instrumentation, configmap, + []collector.LogrotateConfig{logrotateConfig}) } if err == nil { err = errors.WithStack(r.apply(ctx, configmap)) @@ -306,6 +322,14 @@ func (r *Reconciler) generatePGBouncerService( } service.Spec.ExternalTrafficPolicy = initialize.FromPointer(spec.ExternalTrafficPolicy) service.Spec.InternalTrafficPolicy = spec.InternalTrafficPolicy + + // Set IPFamilyPolicy and IPFamilies + if spec.IPFamilyPolicy != nil { + service.Spec.IPFamilyPolicy = spec.IPFamilyPolicy + } + if len(spec.IPFamilies) > 0 { + service.Spec.IPFamilies = spec.IPFamilies + } } service.Spec.Ports = []corev1.ServicePort{servicePort} @@ -371,7 +395,11 @@ func (r *Reconciler) generatePGBouncerDeployment( } deploy.Spec.Template.Annotations = naming.Merge( cluster.Spec.Metadata.GetAnnotationsOrNil(), - cluster.Spec.Proxy.PGBouncer.Metadata.GetAnnotationsOrNil()) + cluster.Spec.Proxy.PGBouncer.Metadata.GetAnnotationsOrNil(), + map[string]string{ + naming.DefaultContainerAnnotation: naming.ContainerPGBouncer, + }, + ) deploy.Spec.Template.Labels = naming.Merge( cluster.Spec.Metadata.GetLabelsOrNil(), cluster.Spec.Proxy.PGBouncer.Metadata.GetLabelsOrNil(), @@ -441,7 +469,20 @@ func (r *Reconciler) generatePGBouncerDeployment( err := errors.WithStack(r.setControllerReference(cluster, deploy)) if err == nil { - pgbouncer.Pod(ctx, cluster, configmap, primaryCertificate, secret, &deploy.Spec.Template.Spec) + pgbouncer.Pod(ctx, cluster, configmap, primaryCertificate, secret, &deploy.Spec.Template) + } + + // Add tmp directory and volume for log files + AddTMPEmptyDir(&deploy.Spec.Template) + + // mount additional volumes to the pgbouncer containers + if err == nil && cluster.Spec.Proxy.PGBouncer.Volumes != nil && len(cluster.Spec.Proxy.PGBouncer.Volumes.Additional) > 0 { + missingContainers := addAdditionalVolumesToSpecifiedContainers(&deploy.Spec.Template, cluster.Spec.Proxy.PGBouncer.Volumes.Additional) + + if len(missingContainers) > 0 { + r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "SpecifiedContainerNotFound", + "The following PgBouncer pod containers were specified for additional volumes but cannot be found: %s.", missingContainers) + } } return deploy, true, err diff --git a/internal/controller/postgrescluster/pgbouncer_test.go b/internal/controller/postgrescluster/pgbouncer_test.go index 9bbced5247..e6df4fbab8 100644 --- a/internal/controller/postgrescluster/pgbouncer_test.go +++ b/internal/controller/postgrescluster/pgbouncer_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,10 +6,10 @@ package postgrescluster import ( "context" + "errors" "strconv" "testing" - "github.com/pkg/errors" "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" policyv1 "k8s.io/api/policy/v1" @@ -105,12 +105,12 @@ ownerReferences: assert.Assert(t, specified) // Annotations present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, service.Annotations, map[string]string{ "a": "v1", }) // Labels present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, service.Labels, map[string]string{ "b": "v2", "postgres-operator.crunchydata.com/cluster": "pg7", "postgres-operator.crunchydata.com/role": "pgbouncer", @@ -136,13 +136,13 @@ ownerReferences: assert.Assert(t, specified) // Annotations present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, service.Annotations, map[string]string{ "a": "v1", "c": "v3", }) // Labels present in the metadata. - assert.DeepEqual(t, service.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, service.Labels, map[string]string{ "b": "v2", "d": "v4", "postgres-operator.crunchydata.com/cluster": "pg7", @@ -420,12 +420,12 @@ namespace: ns3 assert.Assert(t, specified) // Annotations present in the metadata. - assert.DeepEqual(t, deploy.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, deploy.Annotations, map[string]string{ "a": "v1", }) // Labels present in the metadata. - assert.DeepEqual(t, deploy.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, deploy.Labels, map[string]string{ "b": "v2", "postgres-operator.crunchydata.com/cluster": "test-cluster", "postgres-operator.crunchydata.com/role": "pgbouncer", @@ -443,6 +443,7 @@ namespace: ns3 // Annotations present in the pod template. assert.DeepEqual(t, deploy.Spec.Template.Annotations, map[string]string{ "a": "v1", + "kubectl.kubernetes.io/default-container": "pgbouncer", }) // Labels present in the pod template. @@ -508,6 +509,36 @@ topologySpreadConstraints: assert.Assert(t, deploy.Spec.Template.Spec.TopologySpreadConstraints == nil) }) }) + + t.Run("PodSpecWithAdditionalVolumes", func(t *testing.T) { + cluster := cluster.DeepCopy() + cluster.Spec.Proxy.PGBouncer.Volumes = &v1beta1.PGBouncerVolumesSpec{ + Additional: []v1beta1.AdditionalVolume{{ + ClaimName: "required", + Name: "required", + }}, + } + + deploy, specified, err := reconciler.generatePGBouncerDeployment( + ctx, cluster, primary, configmap, secret) + + assert.NilError(t, err) + assert.Assert(t, specified) + + for _, container := range deploy.Spec.Template.Spec.Containers { + assert.Assert(t, cmp.MarshalContains(container.VolumeMounts, + ` +- mountPath: /volumes/required + name: volumes-required`)) + } + + assert.Assert(t, cmp.MarshalContains( + deploy.Spec.Template.Spec.Volumes, + ` +- name: volumes-required + persistentVolumeClaim: + claimName: required`)) + }) } func TestReconcilePGBouncerDisruptionBudget(t *testing.T) { diff --git a/internal/controller/postgrescluster/pgmonitor.go b/internal/controller/postgrescluster/pgmonitor.go index e1b5186cb4..9a6043f868 100644 --- a/internal/controller/postgrescluster/pgmonitor.go +++ b/internal/controller/postgrescluster/pgmonitor.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,6 +6,7 @@ package postgrescluster import ( "context" + _ "embed" "fmt" "io" "os" @@ -15,6 +16,7 @@ import ( corev1 "k8s.io/api/core/v1" "sigs.k8s.io/controller-runtime/pkg/client" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" @@ -27,17 +29,8 @@ import ( "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) -// If pgMonitor is enabled the pgMonitor sidecar(s) have been added to the -// instance pod. reconcilePGMonitor will update the database to -// create the necessary objects for the tool to run -func (r *Reconciler) reconcilePGMonitor(ctx context.Context, - cluster *v1beta1.PostgresCluster, instances *observedInstances, - monitoringSecret *corev1.Secret) error { - - err := r.reconcilePGMonitorExporter(ctx, cluster, instances, monitoringSecret) - - return err -} +//go:embed "metrics_setup.sql" +var metricsSetupForOTelCollector string // reconcilePGMonitorExporter performs setup the postgres_exporter sidecar // - PodExec to run the sql in the primary database @@ -69,19 +62,24 @@ func (r *Reconciler) reconcilePGMonitorExporter(ctx context.Context, // We use this ImageID and the setup.sql file in the hash we make to see if the operator needs to rerun // the `EnableExporterInPostgreSQL` funcs; that way we are always running // that function against an updated and running pod. - if pgmonitor.ExporterEnabled(cluster) { + + if pgmonitor.ExporterEnabled(ctx, cluster) || collector.OpenTelemetryMetricsEnabled(ctx, cluster) { sql, err := os.ReadFile(fmt.Sprintf("%s/pg%d/setup.sql", pgmonitor.GetQueriesConfigDir(ctx), cluster.Spec.PostgresVersion)) if err != nil { return err } - // TODO: Revisit how pgbackrest_info.sh is used with pgMonitor. - // pgMonitor queries expect a path to a script that runs pgBackRest - // info and provides json output. In the queries yaml for pgBackRest - // the default path is `/usr/bin/pgbackrest-info.sh`. We update - // the path to point to the script in our database image. - setup = strings.ReplaceAll(string(sql), "/usr/bin/pgbackrest-info.sh", - "/opt/crunchy/bin/postgres/pgbackrest_info.sh") + if collector.OpenTelemetryMetricsEnabled(ctx, cluster) { + setup = metricsSetupForOTelCollector + } else { + // TODO: Revisit how pgbackrest_info.sh is used with pgMonitor. + // pgMonitor queries expect a path to a script that runs pgBackRest + // info and provides json output. In the queries yaml for pgBackRest + // the default path is `/usr/bin/pgbackrest-info.sh`. We update + // the path to point to the script in our database image. + setup = strings.ReplaceAll(string(sql), "/usr/bin/pgbackrest-info.sh", + "/opt/crunchy/bin/postgres/pgbackrest_info.sh") + } for _, containerStatus := range writablePod.Status.ContainerStatuses { if containerStatus.Name == naming.ContainerDatabase { @@ -102,9 +100,9 @@ func (r *Reconciler) reconcilePGMonitorExporter(ctx context.Context, return pgmonitor.EnableExporterInPostgreSQL(ctx, exec, monitoringSecret, pgmonitor.ExporterDB, setup) } - if !pgmonitor.ExporterEnabled(cluster) { + if !pgmonitor.ExporterEnabled(ctx, cluster) && !collector.OpenTelemetryMetricsEnabled(ctx, cluster) { action = func(ctx context.Context, exec postgres.Executor) error { - return pgmonitor.DisableExporterInPostgreSQL(ctx, exec) + return pgmonitor.DisableMonitoringUserInPostgres(ctx, exec) } } @@ -160,12 +158,11 @@ func (r *Reconciler) reconcileMonitoringSecret( return nil, err } - if !pgmonitor.ExporterEnabled(cluster) { - // TODO: Checking if the exporter is enabled to determine when monitoring - // secret should be created. If more tools are added to the monitoring - // suite, they could need the secret when the exporter is not enabled. - // This check may need to be updated. - // Exporter is disabled; delete monitoring secret if it exists. + // Checking if the exporter is enabled or OpenTelemetryMetrics feature + // is enabled to determine when monitoring secret should be created, + // since our implementation of the SqlQuery receiver in the OTel Collector + // uses the monitoring user as well. + if !pgmonitor.ExporterEnabled(ctx, cluster) && !collector.OpenTelemetryMetricsEnabled(ctx, cluster) { if err == nil { err = errors.WithStack(r.deleteControlled(ctx, cluster, existing)) } @@ -227,19 +224,6 @@ func (r *Reconciler) reconcileMonitoringSecret( return nil, err } -// addPGMonitorToInstancePodSpec performs the necessary setup to add -// pgMonitor resources on a PodTemplateSpec -func addPGMonitorToInstancePodSpec( - ctx context.Context, - cluster *v1beta1.PostgresCluster, - template *corev1.PodTemplateSpec, - exporterQueriesConfig, exporterWebConfig *corev1.ConfigMap) error { - - err := addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, exporterWebConfig) - - return err -} - // addPGMonitorExporterToInstancePodSpec performs the necessary setup to // add pgMonitor exporter resources to a PodTemplateSpec // TODO (jmckulk): refactor to pass around monitoring secret; Without the secret @@ -249,10 +233,10 @@ func addPGMonitorExporterToInstancePodSpec( ctx context.Context, cluster *v1beta1.PostgresCluster, template *corev1.PodTemplateSpec, - exporterQueriesConfig, exporterWebConfig *corev1.ConfigMap) error { + exporterQueriesConfig, exporterWebConfig *corev1.ConfigMap) { - if !pgmonitor.ExporterEnabled(cluster) { - return nil + if !pgmonitor.ExporterEnabled(ctx, cluster) || collector.OpenTelemetryMetricsEnabled(ctx, cluster) { + return } certSecret := cluster.Spec.Monitoring.PGMonitor.Exporter.CustomTLSSecret @@ -346,7 +330,7 @@ func addPGMonitorExporterToInstancePodSpec( }, }, } - configVolume.VolumeSource.Projected.Sources = append(configVolume.VolumeSource.Projected.Sources, + configVolume.Projected.Sources = append(configVolume.Projected.Sources, defaultConfigVolumeProjection) } @@ -385,21 +369,25 @@ func addPGMonitorExporterToInstancePodSpec( // add the proper label to support Pod discovery by Prometheus per pgMonitor configuration initialize.Labels(template) template.Labels[naming.LabelPGMonitorDiscovery] = "true" - - return nil } // reconcileExporterWebConfig reconciles the configmap containing the webconfig for exporter tls func (r *Reconciler) reconcileExporterWebConfig(ctx context.Context, cluster *v1beta1.PostgresCluster) (*corev1.ConfigMap, error) { + if collector.OpenTelemetryMetricsEnabled(ctx, cluster) { + return nil, nil + } + existing := &corev1.ConfigMap{ObjectMeta: naming.ExporterWebConfigMap(cluster)} err := errors.WithStack(r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing)) if client.IgnoreNotFound(err) != nil { return nil, err } - if !pgmonitor.ExporterEnabled(cluster) || cluster.Spec.Monitoring.PGMonitor.Exporter.CustomTLSSecret == nil { + if !pgmonitor.ExporterEnabled(ctx, cluster) || + collector.OpenTelemetryMetricsEnabled(ctx, cluster) || + cluster.Spec.Monitoring.PGMonitor.Exporter.CustomTLSSecret == nil { // We could still have a NotFound error here so check the err. // If no error that means the configmap is found and needs to be deleted if err == nil { @@ -456,7 +444,7 @@ func (r *Reconciler) reconcileExporterQueriesConfig(ctx context.Context, return nil, err } - if !pgmonitor.ExporterEnabled(cluster) { + if !pgmonitor.ExporterEnabled(ctx, cluster) || collector.OpenTelemetryMetricsEnabled(ctx, cluster) { // We could still have a NotFound error here so check the err. // If no error that means the configmap is found and needs to be deleted if err == nil { diff --git a/internal/controller/postgrescluster/pgmonitor_test.go b/internal/controller/postgrescluster/pgmonitor_test.go index 8d8c8281d0..e4ccaf0d9f 100644 --- a/internal/controller/postgrescluster/pgmonitor_test.go +++ b/internal/controller/postgrescluster/pgmonitor_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -39,7 +39,7 @@ func testExporterCollectorsAnnotation(t *testing.T, ctx context.Context, cluster naming.PostgresExporterCollectorsAnnotation: "wrong-value", }) - assert.NilError(t, addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, queriesConfig, webConfig)) + addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, queriesConfig, webConfig) assert.Equal(t, len(template.Spec.Containers), 1) container := template.Spec.Containers[0] @@ -56,7 +56,7 @@ func testExporterCollectorsAnnotation(t *testing.T, ctx context.Context, cluster naming.PostgresExporterCollectorsAnnotation: "None", }) - assert.NilError(t, addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, queriesConfig, webConfig)) + addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, queriesConfig, webConfig) assert.Equal(t, len(template.Spec.Containers), 1) container := template.Spec.Containers[0] @@ -71,7 +71,7 @@ func testExporterCollectorsAnnotation(t *testing.T, ctx context.Context, cluster naming.PostgresExporterCollectorsAnnotation: "none", }) - assert.NilError(t, addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, queriesConfig, webConfig)) + addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, queriesConfig, webConfig) assert.Assert(t, cmp.Contains(strings.Join(template.Spec.Containers[0].Command, "\n"), "--[no-]collector")) }) }) @@ -100,7 +100,7 @@ func TestAddPGMonitorExporterToInstancePodSpec(t *testing.T) { t.Run("ExporterDisabled", func(t *testing.T) { template := &corev1.PodTemplateSpec{} - assert.NilError(t, addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, nil, nil)) + addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, nil, nil) assert.DeepEqual(t, template, &corev1.PodTemplateSpec{}) }) @@ -121,8 +121,7 @@ func TestAddPGMonitorExporterToInstancePodSpec(t *testing.T) { }, } - assert.NilError(t, addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, nil)) - + addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, nil) assert.Equal(t, len(template.Spec.Containers), 2) container := template.Spec.Containers[1] @@ -205,8 +204,7 @@ volumeMounts: }, } - assert.NilError(t, addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, nil)) - + addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, nil) assert.Equal(t, len(template.Spec.Containers), 2) container := template.Spec.Containers[1] @@ -255,8 +253,7 @@ name: exporter-config }, } - assert.NilError(t, addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, nil)) - + addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, nil) assert.Equal(t, len(template.Spec.Containers), 2) container := template.Spec.Containers[1] @@ -301,8 +298,7 @@ name: exporter-config testConfigMap := new(corev1.ConfigMap) testConfigMap.Name = "test-web-conf" - assert.NilError(t, addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, testConfigMap)) - + addPGMonitorExporterToInstancePodSpec(ctx, cluster, template, exporterQueriesConfig, testConfigMap) assert.Equal(t, len(template.Spec.Containers), 2) container := template.Spec.Containers[1] @@ -358,7 +354,7 @@ func TestReconcilePGMonitorExporterSetupErrors(t *testing.T) { Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ Name: "daisy-pod", - Annotations: map[string]string{"status": `{"role":"master"}`}, + Annotations: map[string]string{"status": `{"role":"primary"}`}, DeletionTimestamp: &metav1.Time{}, }, }}, @@ -388,7 +384,7 @@ func TestReconcilePGMonitorExporterSetupErrors(t *testing.T) { Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ Name: "daisy-pod", - Annotations: map[string]string{"status": `{"role":"master"}`}, + Annotations: map[string]string{"status": `{"role":"primary"}`}, }, }}, Runner: &appsv1.StatefulSet{}, @@ -410,7 +406,7 @@ func TestReconcilePGMonitorExporterSetupErrors(t *testing.T) { Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ Name: "daisy-pod", - Annotations: map[string]string{"status": `{"role":"master"}`}, + Annotations: map[string]string{"status": `{"role":"primary"}`}, }, Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{{ @@ -438,7 +434,7 @@ func TestReconcilePGMonitorExporterSetupErrors(t *testing.T) { Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ Name: "daisy-pod", - Annotations: map[string]string{"status": `{"role":"master"}`}, + Annotations: map[string]string{"status": `{"role":"primary"}`}, }, Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{{ @@ -469,7 +465,7 @@ func TestReconcilePGMonitorExporterSetupErrors(t *testing.T) { Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ Name: "daisy-pod", - Annotations: map[string]string{"status": `{"role":"master"}`}, + Annotations: map[string]string{"status": `{"role":"primary"}`}, }, Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{{ @@ -536,7 +532,7 @@ func TestReconcilePGMonitorExporter(t *testing.T) { Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ Name: "one-daisy-pod", - Annotations: map[string]string{"status": `{"role":"master"}`}, + Annotations: map[string]string{"status": `{"role":"primary"}`}, }, Status: corev1.PodStatus{ Phase: corev1.PodRunning, @@ -602,7 +598,7 @@ func TestReconcilePGMonitorExporterStatus(t *testing.T) { podExecCalled: false, // Status was generated manually for this test case // TODO (jmckulk): add code to generate status - status: v1beta1.MonitoringStatus{ExporterConfiguration: "6d874c58df"}, + status: v1beta1.MonitoringStatus{ExporterConfiguration: "86cdc4f778"}, statusChangedAfterReconcile: false, }} { t.Run(test.name, func(t *testing.T) { @@ -634,7 +630,7 @@ func TestReconcilePGMonitorExporterStatus(t *testing.T) { Pods: []*corev1.Pod{{ ObjectMeta: metav1.ObjectMeta{ Name: "daisy-pod", - Annotations: map[string]string{"status": `{"role":"master"}`}, + Annotations: map[string]string{"status": `{"role":"primary"}`}, }, Status: corev1.PodStatus{ ContainerStatuses: []corev1.ContainerStatus{{ diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index 0314ad4406..d52d6a75da 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -63,7 +63,7 @@ func (r *Reconciler) reconcileRootCertificate( intent.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) intent.Namespace, intent.Name = cluster.Namespace, naming.RootCertSecret intent.Data = make(map[string][]byte) - intent.ObjectMeta.OwnerReferences = existing.ObjectMeta.OwnerReferences + intent.OwnerReferences = existing.OwnerReferences // A root secret is scoped to the namespace where postgrescluster(s) // are deployed. For operator deployments with postgresclusters in more than @@ -140,7 +140,7 @@ func (r *Reconciler) reconcileClusterCertificate( intent := &corev1.Secret{ObjectMeta: naming.PostgresTLSSecret(cluster)} intent.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) intent.Data = make(map[string][]byte) - intent.ObjectMeta.OwnerReferences = existing.ObjectMeta.OwnerReferences + intent.OwnerReferences = existing.OwnerReferences intent.Annotations = naming.Merge(cluster.Spec.Metadata.GetAnnotationsOrNil()) intent.Labels = naming.Merge( diff --git a/internal/controller/postgrescluster/pki_test.go b/internal/controller/postgrescluster/pki_test.go index c2fe7af82a..0cb5f15a99 100644 --- a/internal/controller/postgrescluster/pki_test.go +++ b/internal/controller/postgrescluster/pki_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -12,7 +12,6 @@ import ( "strings" "testing" - "github.com/pkg/errors" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -90,7 +89,7 @@ func TestReconcileCerts(t *testing.T) { err := tClient.Get(ctx, client.ObjectKeyFromObject(rootSecret), rootSecret) assert.NilError(t, err) - assert.Check(t, len(rootSecret.ObjectMeta.OwnerReferences) == 1, "first owner reference not set") + assert.Check(t, len(rootSecret.OwnerReferences) == 1, "first owner reference not set") expectedOR := metav1.OwnerReference{ APIVersion: "postgres-operator.crunchydata.com/v1beta1", @@ -99,8 +98,8 @@ func TestReconcileCerts(t *testing.T) { UID: cluster1.UID, } - if len(rootSecret.ObjectMeta.OwnerReferences) > 0 { - assert.Equal(t, rootSecret.ObjectMeta.OwnerReferences[0], expectedOR) + if len(rootSecret.OwnerReferences) > 0 { + assert.Equal(t, rootSecret.OwnerReferences[0], expectedOR) } }) @@ -115,7 +114,7 @@ func TestReconcileCerts(t *testing.T) { clist := &v1beta1.PostgresClusterList{} assert.NilError(t, tClient.List(ctx, clist)) - assert.Check(t, len(rootSecret.ObjectMeta.OwnerReferences) == 2, "second owner reference not set") + assert.Check(t, len(rootSecret.OwnerReferences) == 2, "second owner reference not set") expectedOR := metav1.OwnerReference{ APIVersion: "postgres-operator.crunchydata.com/v1beta1", @@ -124,8 +123,8 @@ func TestReconcileCerts(t *testing.T) { UID: cluster2.UID, } - if len(rootSecret.ObjectMeta.OwnerReferences) > 1 { - assert.Equal(t, rootSecret.ObjectMeta.OwnerReferences[1], expectedOR) + if len(rootSecret.OwnerReferences) > 1 { + assert.Equal(t, rootSecret.OwnerReferences[1], expectedOR) } }) @@ -145,8 +144,7 @@ func TestReconcileCerts(t *testing.T) { emptyRootSecret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) emptyRootSecret.Namespace, emptyRootSecret.Name = namespace, naming.RootCertSecret emptyRootSecret.Data = make(map[string][]byte) - err = errors.WithStack(r.apply(ctx, emptyRootSecret)) - assert.NilError(t, err) + assert.NilError(t, r.apply(ctx, emptyRootSecret)) // reconcile the root cert secret, creating a new root cert returnedRoot, err := r.reconcileRootCertificate(ctx, cluster1) @@ -206,7 +204,7 @@ func TestReconcileCerts(t *testing.T) { emptyRootSecret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) emptyRootSecret.Namespace, emptyRootSecret.Name = namespace, naming.RootCertSecret emptyRootSecret.Data = make(map[string][]byte) - err = errors.WithStack(r.apply(ctx, emptyRootSecret)) + assert.NilError(t, r.apply(ctx, emptyRootSecret)) // reconcile the root cert secret newRootCert, err := r.reconcileRootCertificate(ctx, cluster1) @@ -303,7 +301,7 @@ func TestReconcileCerts(t *testing.T) { testSecret := &corev1.Secret{} testSecret.Namespace, testSecret.Name = namespace, "newcustomsecret" // simulate cluster spec update - cluster2.Spec.CustomTLSSecret.LocalObjectReference.Name = "newcustomsecret" + cluster2.Spec.CustomTLSSecret.Name = "newcustomsecret" // get the expected secret projection testSecretProjection := clusterCertSecretProjection(testSecret) @@ -331,8 +329,7 @@ func TestReconcileCerts(t *testing.T) { emptyRootSecret.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) emptyRootSecret.Namespace, emptyRootSecret.Name = namespace, naming.RootCertSecret emptyRootSecret.Data = make(map[string][]byte) - err = errors.WithStack(r.apply(ctx, emptyRootSecret)) - assert.NilError(t, err) + assert.NilError(t, r.apply(ctx, emptyRootSecret)) // reconcile the root cert secret, creating a new root cert returnedRoot, err := r.reconcileRootCertificate(ctx, cluster1) @@ -392,7 +389,7 @@ func getCertFromSecret( // get the cert from the secret secretCRT, ok := secret.Data[dataKey] if !ok { - return nil, errors.New(fmt.Sprintf("could not retrieve %s", dataKey)) + return nil, fmt.Errorf("could not retrieve %s", dataKey) } // parse the cert from binary encoded data diff --git a/internal/controller/postgrescluster/pod_disruption_budget.go b/internal/controller/postgrescluster/pod_disruption_budget.go index 4bff4a9743..80ad33b55e 100644 --- a/internal/controller/postgrescluster/pod_disruption_budget.go +++ b/internal/controller/postgrescluster/pod_disruption_budget.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/pod_disruption_budget_test.go b/internal/controller/postgrescluster/pod_disruption_budget_test.go index 55e2bb63c6..6463068d4c 100644 --- a/internal/controller/postgrescluster/pod_disruption_budget_test.go +++ b/internal/controller/postgrescluster/pod_disruption_budget_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/postgres.go b/internal/controller/postgrescluster/postgres.go index 312079d824..4dd4a9d78a 100644 --- a/internal/controller/postgrescluster/postgres.go +++ b/internal/controller/postgrescluster/postgres.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,6 +6,7 @@ package postgrescluster import ( "bytes" + "cmp" "context" "fmt" "io" @@ -18,8 +19,8 @@ import ( "github.com/pkg/errors" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/client" @@ -28,7 +29,11 @@ import ( "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/patroni" "github.com/crunchydata/postgres-operator/internal/pgaudit" + "github.com/crunchydata/postgres-operator/internal/pgbackrest" + "github.com/crunchydata/postgres-operator/internal/pgbouncer" + "github.com/crunchydata/postgres-operator/internal/pgmonitor" "github.com/crunchydata/postgres-operator/internal/postgis" "github.com/crunchydata/postgres-operator/internal/postgres" pgpassword "github.com/crunchydata/postgres-operator/internal/postgres/password" @@ -36,13 +41,152 @@ import ( "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) +// generatePostgresHBA converts one API rule into a structured HBA rule that +// safely formats its values. +func (*Reconciler) generatePostgresHBA(spec *v1beta1.PostgresHBARule) *postgres.HostBasedAuthentication { + if spec == nil { + return nil + } + + result := postgres.NewHBA() + result.Origin(spec.Connection) + + // The "password" method is not recommended. More likely, the user wants to + // use passwords generally. The "scram-sha-256" method is the preferred way + // to do that. + // - https://www.postgresql.org/docs/current/auth-password.html + if spec.Method == "password" { + result.Method("scram-sha-256") + } else { + result.Method(spec.Method) + } + + if len(spec.Databases) > 0 { + result.Databases(spec.Databases[0], spec.Databases[1:]...) + } + if len(spec.Users) > 0 { + result.Users(spec.Users[0], spec.Users[1:]...) + } + if len(spec.Options) > 0 { + opts := make(map[string]string, len(spec.Options)) + for k, v := range spec.Options { + opts[k] = v.String() + } + result.Options(opts) + } + + return result +} + +// generatePostgresHBAs produces the HBA rules for cluster that incorporates, +// from highest to lowest precedence: +// 1. mandatory rules determined by controllers +// 2. rules in cluster.spec.patroni.dynamicConfiguration +// 3. default rules, when none were in cluster.spec +func (r *Reconciler) generatePostgresHBAs( + ctx context.Context, cluster *v1beta1.PostgresCluster, +) *postgres.OrderedHBAs { + builtin := postgres.NewHBAs() + pgmonitor.PostgreSQLHBAs(ctx, cluster, &builtin) + pgbouncer.PostgreSQL(cluster, &builtin) + + // Postgres processes HBA rules in order. Start with mandatory rules + // so connections are matched against them first. + result := new(postgres.OrderedHBAs) + result.Append(builtin.Mandatory...) + mandatory := result.Length() + + // Append any rules specified in the Authentication section. + // These take precedence over any in the Patroni section. + if authn := cluster.Spec.Authentication; authn != nil { + for _, in := range authn.Rules { + if len(in.HBA) > 0 { + result.AppendUnstructured(in.HBA) + } else { + result.Append(r.generatePostgresHBA(&in.PostgresHBARule)) + } + } + } + + // Append any rules specified in the Patroni section. + result.AppendUnstructured(patroni.PostgresHBAs(cluster.Spec.Patroni)...) + + // When there are no specified rules, include the recommended defaults. + if result.Length() == mandatory { + result.Append(builtin.Default...) + } + + return result +} + +// generatePostgresParameters produces the parameter set for cluster that +// incorporates, from highest to lowest precedence: +// 1. mandatory values determined by controllers +// 2. parameters in cluster.spec.config.parameters +// 3. parameters in cluster.spec.patroni.dynamicConfiguration +// 4. default values determined by contollers +func (*Reconciler) generatePostgresParameters( + ctx context.Context, cluster *v1beta1.PostgresCluster, backupsSpecFound bool, +) *postgres.ParameterSet { + builtin := postgres.NewParameters() + pgaudit.PostgreSQLParameters(&builtin) + pgbackrest.PostgreSQLParameters(cluster, &builtin, backupsSpecFound) + pgmonitor.PostgreSQLParameters(ctx, cluster, &builtin) + postgres.SetHugePages(cluster, &builtin) + + // Last write wins, so start with the recommended defaults. + result := cmp.Or(builtin.Default.DeepCopy(), postgres.NewParameterSet()) + + // Overwrite the above with any parameters specified in the Patroni section. + for k, v := range patroni.PostgresParameters(cluster.Spec.Patroni).AsMap() { + result.Add(k, v) + } + + // Overwrite the above with any parameters specified in the Config section. + if config := cluster.Spec.Config; config != nil { + for k, v := range config.Parameters { + result.Add(k, v.String()) + } + } + + // Overwrite the above with mandatory values. + if builtin.Mandatory != nil { + // This parameter is a comma-separated list. Rather than overwrite the + // user-defined value, we want to combine it with the mandatory one. + preload := result.Value("shared_preload_libraries") + + for k, v := range builtin.Mandatory.AsMap() { + // Load mandatory libraries ahead of user-defined libraries. + if k == "shared_preload_libraries" && len(v) > 0 && len(preload) > 0 { + v = v + "," + preload + } + + result.Add(k, v) + } + } + + // Some preload libraries belong at specific positions in this list. + if preload, ok := result.Get("shared_preload_libraries"); ok { + // Load "citus" ahead of any other libraries. + // - https://github.com/citusdata/citus/blob/v12.0.0/src/backend/distributed/shared_library_init.c#L417-L419 + // - https://github.com/citusdata/citus/blob/v13.0.0/src/backend/distributed/shared_library_init.c#L420-L422 + if strings.Contains(preload, "citus") { + preload = "citus," + preload + } + + result.Add("shared_preload_libraries", preload) + } + + return result +} + // generatePostgresUserSecret returns a Secret containing a password and // connection details for the first database in spec. When existing is nil or // lacks a password or verifier, a new password and verifier are generated. func (r *Reconciler) generatePostgresUserSecret( cluster *v1beta1.PostgresCluster, spec *v1beta1.PostgresUserSpec, existing *corev1.Secret, ) (*corev1.Secret, error) { - username := string(spec.Name) + username := spec.Name intent := &corev1.Secret{ObjectMeta: naming.PostgresUserSecret(cluster, username)} intent.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Secret")) initialize.Map(&intent.Data) @@ -99,7 +243,7 @@ func (r *Reconciler) generatePostgresUserSecret( // When a database has been specified, include it and a connection URI. // - https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING if len(spec.Databases) > 0 { - database := string(spec.Databases[0]) + database := spec.Databases[0] intent.Data["dbname"] = []byte(database) intent.Data["uri"] = []byte((&url.URL{ @@ -132,7 +276,7 @@ func (r *Reconciler) generatePostgresUserSecret( intent.Data["pgbouncer-port"] = []byte(port) if len(spec.Databases) > 0 { - database := string(spec.Databases[0]) + database := spec.Databases[0] intent.Data["pgbouncer-uri"] = []byte((&url.URL{ Scheme: "postgresql", @@ -215,9 +359,7 @@ func (r *Reconciler) reconcilePostgresDatabases( } } else { for _, user := range cluster.Spec.Users { - for _, database := range user.Databases { - databases.Insert(string(database)) - } + databases.Insert(user.Databases...) } } @@ -378,10 +520,9 @@ func (r *Reconciler) reconcilePostgresUserSecrets( r.Recorder.Event(cluster, corev1.EventTypeWarning, "InvalidUser", allErrors.ToAggregate().Error()) } else { - identifier := v1beta1.PostgresIdentifier(cluster.Name) specUsers = []v1beta1.PostgresUserSpec{{ - Name: identifier, - Databases: []v1beta1.PostgresIdentifier{identifier}, + Name: cluster.Name, + Databases: []string{cluster.Name}, }} } } @@ -389,7 +530,7 @@ func (r *Reconciler) reconcilePostgresUserSecrets( // Index user specifications by PostgreSQL user name. userSpecs := make(map[string]*v1beta1.PostgresUserSpec, len(specUsers)) for i := range specUsers { - userSpecs[string(specUsers[i].Name)] = &specUsers[i] + userSpecs[specUsers[i].Name] = &specUsers[i] } secrets := &corev1.SecretList{} @@ -429,7 +570,7 @@ func (r *Reconciler) reconcilePostgresUserSecrets( // If both secrets have "pguser" or neither have "pguser", // sort by creation timestamp - return secrets.Items[i].CreationTimestamp.Time.After(secrets.Items[j].CreationTimestamp.Time) + return secrets.Items[i].CreationTimestamp.After(secrets.Items[j].CreationTimestamp.Time) }) // Index secrets by PostgreSQL user name and delete any that are not in the @@ -502,11 +643,11 @@ func (r *Reconciler) reconcilePostgresUsersInPostgreSQL( running, known := instance.IsRunning(container) if running && known && len(instance.Pods) > 0 { pod := instance.Pods[0] - ctx = logging.NewContext(ctx, logging.FromContext(ctx).WithValues("pod", pod.Name)) podExecutor = func( ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, command ...string, ) error { + ctx = logging.NewContext(ctx, logging.FromContext(ctx).WithValues("pod", pod.Name)) return r.PodExec(ctx, pod.Namespace, pod.Name, container, stdin, stdout, stderr, command...) } break @@ -569,7 +710,7 @@ func (r *Reconciler) reconcilePostgresUsersInPostgreSQL( func (r *Reconciler) reconcilePostgresDataVolume( ctx context.Context, cluster *v1beta1.PostgresCluster, instanceSpec *v1beta1.PostgresInstanceSetSpec, instance *appsv1.StatefulSet, - clusterVolumes []corev1.PersistentVolumeClaim, sourceCluster *v1beta1.PostgresCluster, + clusterVolumes []*corev1.PersistentVolumeClaim, sourceCluster *v1beta1.PostgresCluster, ) (*corev1.PersistentVolumeClaim, error) { labelMap := map[string]string{ @@ -581,10 +722,7 @@ func (r *Reconciler) reconcilePostgresDataVolume( } var pvc *corev1.PersistentVolumeClaim - existingPVCName, err := getPGPVCName(labelMap, clusterVolumes) - if err != nil { - return nil, errors.WithStack(err) - } + existingPVCName := getPVCName(clusterVolumes, labels.SelectorFromSet(labelMap)) if existingPVCName != "" { pvc = &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{ Namespace: cluster.GetNamespace(), @@ -596,7 +734,7 @@ func (r *Reconciler) reconcilePostgresDataVolume( pvc.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("PersistentVolumeClaim")) - err = errors.WithStack(r.setControllerReference(cluster, pvc)) + err := errors.WithStack(r.setControllerReference(cluster, pvc)) pvc.Annotations = naming.Merge( cluster.Spec.Metadata.GetAnnotationsOrNil(), @@ -608,7 +746,7 @@ func (r *Reconciler) reconcilePostgresDataVolume( labelMap, ) - pvc.Spec = instanceSpec.DataVolumeClaimSpec + pvc.Spec = instanceSpec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() // If a source cluster was provided and VolumeSnapshots are turned on in the source cluster and // there is a VolumeSnapshot available for the source cluster that is ReadyToUse, use it as the @@ -636,7 +774,7 @@ func (r *Reconciler) reconcilePostgresDataVolume( } } - r.setVolumeSize(ctx, cluster, pvc, instanceSpec.Name) + r.setVolumeSize(ctx, cluster, pvc, "pgData", instanceSpec.Name) // Clear any set limit before applying PVC. This is needed to allow the limit // value to change later. @@ -650,75 +788,6 @@ func (r *Reconciler) reconcilePostgresDataVolume( return pvc, err } -// setVolumeSize compares the potential sizes from the instance spec, status -// and limit and sets the appropriate current value. -func (r *Reconciler) setVolumeSize(ctx context.Context, cluster *v1beta1.PostgresCluster, - pvc *corev1.PersistentVolumeClaim, instanceSpecName string) { - log := logging.FromContext(ctx) - - // Store the limit for this instance set. This value will not change below. - volumeLimitFromSpec := pvc.Spec.Resources.Limits.Storage() - - // Capture the largest pgData volume size currently defined for a given instance set. - // This value will capture our desired update. - volumeRequestSize := pvc.Spec.Resources.Requests.Storage() - - // If the request value is greater than the set limit, use the limit and issue - // a warning event. A limit of 0 is ignorned. - if !volumeLimitFromSpec.IsZero() && - volumeRequestSize.Value() > volumeLimitFromSpec.Value() { - r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "VolumeRequestOverLimit", - "pgData volume request (%v) for %s/%s is greater than set limit (%v). Limit value will be used.", - volumeRequestSize, cluster.Name, instanceSpecName, volumeLimitFromSpec) - - pvc.Spec.Resources.Requests = corev1.ResourceList{ - corev1.ResourceStorage: *resource.NewQuantity(volumeLimitFromSpec.Value(), resource.BinarySI), - } - // Otherwise, if the limit is not set or the feature gate is not enabled, do not autogrow. - } else if !volumeLimitFromSpec.IsZero() && feature.Enabled(ctx, feature.AutoGrowVolumes) { - for i := range cluster.Status.InstanceSets { - if instanceSpecName == cluster.Status.InstanceSets[i].Name { - for _, dpv := range cluster.Status.InstanceSets[i].DesiredPGDataVolume { - if dpv != "" { - desiredRequest, err := resource.ParseQuantity(dpv) - if err == nil { - if desiredRequest.Value() > volumeRequestSize.Value() { - volumeRequestSize = &desiredRequest - } - } else { - log.Error(err, "Unable to parse volume request: "+dpv) - } - } - } - } - } - - // If the volume request size is greater than or equal to the limit and the - // limit is not zero, update the request size to the limit value. - // If the user manually requests a lower limit that is smaller than the current - // or requested volume size, it will be ignored in favor of the limit value. - if volumeRequestSize.Value() >= volumeLimitFromSpec.Value() { - - r.Recorder.Eventf(cluster, corev1.EventTypeNormal, "VolumeLimitReached", - "pgData volume(s) for %s/%s are at size limit (%v).", cluster.Name, - instanceSpecName, volumeLimitFromSpec) - - // If the volume size request is greater than the limit, issue an - // additional event warning. - if volumeRequestSize.Value() > volumeLimitFromSpec.Value() { - r.Recorder.Eventf(cluster, corev1.EventTypeWarning, "DesiredVolumeAboveLimit", - "The desired size (%v) for the %s/%s pgData volume(s) is greater than the size limit (%v).", - volumeRequestSize, cluster.Name, instanceSpecName, volumeLimitFromSpec) - } - - volumeRequestSize = volumeLimitFromSpec - } - pvc.Spec.Resources.Requests = corev1.ResourceList{ - corev1.ResourceStorage: *resource.NewQuantity(volumeRequestSize.Value(), resource.BinarySI), - } - } -} - // +kubebuilder:rbac:groups="",resources="persistentvolumeclaims",verbs={create,patch} // reconcileTablespaceVolumes writes the PersistentVolumeClaims for instance's @@ -726,7 +795,7 @@ func (r *Reconciler) setVolumeSize(ctx context.Context, cluster *v1beta1.Postgre func (r *Reconciler) reconcileTablespaceVolumes( ctx context.Context, cluster *v1beta1.PostgresCluster, instanceSpec *v1beta1.PostgresInstanceSetSpec, instance *appsv1.StatefulSet, - clusterVolumes []corev1.PersistentVolumeClaim, + clusterVolumes []*corev1.PersistentVolumeClaim, ) (tablespaceVolumes []*corev1.PersistentVolumeClaim, err error) { if !feature.Enabled(ctx, feature.TablespaceVolumes) { @@ -747,10 +816,7 @@ func (r *Reconciler) reconcileTablespaceVolumes( } var pvc *corev1.PersistentVolumeClaim - existingPVCName, err := getPGPVCName(labelMap, clusterVolumes) - if err != nil { - return nil, errors.WithStack(err) - } + existingPVCName := getPVCName(clusterVolumes, labels.SelectorFromSet(labelMap)) if existingPVCName != "" { pvc = &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{ Namespace: cluster.GetNamespace(), @@ -774,7 +840,7 @@ func (r *Reconciler) reconcileTablespaceVolumes( labelMap, ) - pvc.Spec = vol.DataVolumeClaimSpec + pvc.Spec = vol.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() if err == nil { err = r.handlePersistentVolumeClaimError(cluster, @@ -799,7 +865,7 @@ func (r *Reconciler) reconcileTablespaceVolumes( func (r *Reconciler) reconcilePostgresWALVolume( ctx context.Context, cluster *v1beta1.PostgresCluster, instanceSpec *v1beta1.PostgresInstanceSetSpec, instance *appsv1.StatefulSet, - observed *Instance, clusterVolumes []corev1.PersistentVolumeClaim, + observed *Instance, clusterVolumes []*corev1.PersistentVolumeClaim, ) (*corev1.PersistentVolumeClaim, error) { labelMap := map[string]string{ @@ -811,10 +877,7 @@ func (r *Reconciler) reconcilePostgresWALVolume( } var pvc *corev1.PersistentVolumeClaim - existingPVCName, err := getPGPVCName(labelMap, clusterVolumes) - if err != nil { - return nil, errors.WithStack(err) - } + existingPVCName := getPVCName(clusterVolumes, labels.SelectorFromSet(labelMap)) if existingPVCName != "" { pvc = &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{ Namespace: cluster.GetNamespace(), @@ -872,7 +935,7 @@ func (r *Reconciler) reconcilePostgresWALVolume( return pvc, err } - err = errors.WithStack(r.setControllerReference(cluster, pvc)) + err := errors.WithStack(r.setControllerReference(cluster, pvc)) pvc.Annotations = naming.Merge( cluster.Spec.Metadata.GetAnnotationsOrNil(), @@ -884,7 +947,7 @@ func (r *Reconciler) reconcilePostgresWALVolume( labelMap, ) - pvc.Spec = *instanceSpec.WALVolumeClaimSpec + pvc.Spec = instanceSpec.WALVolumeClaimSpec.AsPersistentVolumeClaimSpec() if err == nil { err = r.handlePersistentVolumeClaimError(cluster, diff --git a/internal/controller/postgrescluster/postgres_test.go b/internal/controller/postgrescluster/postgres_test.go index 0780b0f577..e9b6432886 100644 --- a/internal/controller/postgrescluster/postgres_test.go +++ b/internal/controller/postgrescluster/postgres_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,27 +6,25 @@ package postgrescluster import ( "context" + "errors" "fmt" "io" + "os" + "strings" "testing" - "github.com/go-logr/logr/funcr" "github.com/google/go-cmp/cmp/cmpopts" volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" - "github.com/pkg/errors" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/yaml" "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" - "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/postgres" "github.com/crunchydata/postgres-operator/internal/testing/cmp" @@ -35,6 +33,251 @@ import ( "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) +func TestGeneratePostgresHBA(t *testing.T) { + reconciler := &Reconciler{} + + assert.Assert(t, reconciler.generatePostgresHBA(nil) == nil, + "expected nil to return nil") + + for _, tt := range []struct { + rule, expected string + }{ + { + rule: `{ connection: host, method: scram }`, + expected: `"host" all all all "scram"`, + }, + { + rule: `{ connection: local, method: peer, databases: [one, two] }`, + expected: `"local" "one","two" all all "peer"`, + }, + { + rule: `{ connection: local, method: peer, users: [alice, bob] }`, + expected: `"local" all "alice","bob" all "peer"`, + }, + { + rule: `{ connection: hostssl, method: md5, options: { clientcert: verify-ca } }`, + expected: `"hostssl" all all all "md5" "clientcert"="verify-ca"`, + }, + // "password" input should be "scram-sha-256" output + { + rule: `{ connection: hostssl, method: password }`, + expected: `"hostssl" all all all "scram-sha-256"`, + }, + } { + var rule *v1beta1.PostgresHBARule + require.UnmarshalInto(t, &rule, tt.rule) + + hba := reconciler.generatePostgresHBA(rule) + assert.Equal(t, hba.String(), tt.expected, "\n%#v", rule) + } +} + +func TestGeneratePostgresHBAs(t *testing.T) { + ctx := context.Background() + reconciler := &Reconciler{} + + builtin := reconciler.generatePostgresHBAs(ctx, v1beta1.NewPostgresCluster()).AsStrings() + assert.Assert(t, len(builtin) > 0, + "expected an empty cluster to have some builtin rules") + + defaults := builtin[len(builtin)-1:] + assert.Assert(t, len(defaults) > 0, + "expected at least one default rule") + + required := builtin[:len(builtin)-len(defaults)] + assert.Assert(t, len(required) > 0, + "expected at least one mandatory rule") + + t.Run("Authentication", func(t *testing.T) { + cluster := v1beta1.NewPostgresCluster() + require.UnmarshalInto(t, &cluster.Spec.Authentication, `{ + rules: [ + { connection: host, method: scram }, + { connection: local, method: peer, users: [alice, bob] }, + ], + }`) + + result := reconciler.generatePostgresHBAs(ctx, cluster).AsStrings() + assert.Assert(t, cmp.Len(result, len(required)+2), + "expected two rules from the Authentication section and no defaults") + + // mandatory rules should be first + assert.DeepEqual(t, result[:len(required)], required) + + // specified rules should be last and in their original order + assert.DeepEqual(t, result[len(required):], []string{ + `"host" all all all "scram"`, + `"local" all "alice","bob" all "peer"`, + }) + }) + + t.Run("Patroni", func(t *testing.T) { + cluster := v1beta1.NewPostgresCluster() + require.UnmarshalInto(t, &cluster.Spec.Patroni, `{ + dynamicConfiguration: { + postgresql: { pg_hba: [ "first custom", "another" ] }, + }, + }`) + + result := reconciler.generatePostgresHBAs(ctx, cluster).AsStrings() + assert.Assert(t, cmp.Len(result, len(required)+2), + "expected two rules from the Patroni section and no defaults") + + // mandatory rules should be first + assert.DeepEqual(t, result[:len(required)], required) + + // specified rules should be last and in their original order + assert.DeepEqual(t, result[len(required):], []string{`first custom`, `another`}) + }) + + t.Run("Precedence", func(t *testing.T) { + cluster := v1beta1.NewPostgresCluster() + require.UnmarshalInto(t, &cluster.Spec.Authentication, `{ + rules: [ + { connection: host, method: scram }, + { connection: local, method: peer, users: [alice, bob] }, + ], + }`) + require.UnmarshalInto(t, &cluster.Spec.Patroni, `{ + dynamicConfiguration: { + postgresql: { pg_hba: [ "another" ] }, + }, + }`) + + result := reconciler.generatePostgresHBAs(ctx, cluster).AsStrings() + assert.Assert(t, cmp.Len(result, len(required)+2+1), + "expected two rules from the Authentication section"+ + " plus one from the Patroni section") + + // mandatory rules should be first + assert.DeepEqual(t, result[:len(required)], required) + + // specified rules are next, no defaults + assert.DeepEqual(t, result[len(required):], []string{ + `"host" all all all "scram"`, // Authentication + `"local" all "alice","bob" all "peer"`, // Authentication + `another`, // Patroni + }) + }) +} + +func TestGeneratePostgresParameters(t *testing.T) { + ctx := context.Background() + reconciler := &Reconciler{} + + builtin := reconciler.generatePostgresParameters(ctx, v1beta1.NewPostgresCluster(), false) + assert.Assert(t, len(builtin.AsMap()) > 0, + "expected an empty cluster to have some builtin parameters") + + assert.Equal(t, builtin.Value("jit"), "off", + "BUG IN TEST: expected JIT to be disabled") + + assert.Equal(t, builtin.Value("shared_preload_libraries"), "pgaudit", + "BUG IN TEST: expected pgAudit to be mandatory") + + t.Run("Config", func(t *testing.T) { + cluster := v1beta1.NewPostgresCluster() + require.UnmarshalInto(t, &cluster.Spec.Config, `{ + parameters: { + something: str, + another: 5, + }, + }`) + + result := reconciler.generatePostgresParameters(ctx, cluster, false) + assert.Assert(t, cmp.LenMap(result.AsMap(), len(builtin.AsMap())+2), + "expected two parameters from the Config section") + + assert.Equal(t, result.Value("another"), "5") + assert.Equal(t, result.Value("something"), "str") + }) + + t.Run("Patroni", func(t *testing.T) { + cluster := v1beta1.NewPostgresCluster() + require.UnmarshalInto(t, &cluster.Spec.Patroni, `{ + dynamicConfiguration: { + postgresql: { parameters: { + something: str, + another: 5.1, + } }, + }, + }`) + + result := reconciler.generatePostgresParameters(ctx, cluster, false) + assert.Assert(t, cmp.LenMap(result.AsMap(), len(builtin.AsMap())+2), + "expected two parameters from the Patroni section") + + assert.Equal(t, result.Value("another"), "5.1") + assert.Equal(t, result.Value("something"), "str") + }) + + t.Run("Precedence", func(t *testing.T) { + cluster := v1beta1.NewPostgresCluster() + require.UnmarshalInto(t, &cluster.Spec.Config, `{ + parameters: { + something: replaced, + unrelated: used, + jit: "on", + }, + }`) + require.UnmarshalInto(t, &cluster.Spec.Patroni, `{ + dynamicConfiguration: { + postgresql: { parameters: { + something: str, + another: 5.1, + } }, + }, + }`) + + result := reconciler.generatePostgresParameters(ctx, cluster, false) + assert.Assert(t, cmp.LenMap(result.AsMap(), len(builtin.AsMap())+3+1-1), + "expected three parameters from the Config section,"+ + "plus one from the Patroni section, minus one default") + + assert.Equal(t, result.Value("another"), "5.1") // Patroni + assert.Equal(t, result.Value("something"), "replaced") // Config + assert.Equal(t, result.Value("unrelated"), "used") // Config + assert.Equal(t, result.Value("jit"), "on") // Config + }) + + t.Run("shared_preload_libraries", func(t *testing.T) { + t.Run("NumericIncluded", func(t *testing.T) { + cluster := v1beta1.NewPostgresCluster() + require.UnmarshalInto(t, &cluster.Spec.Config, `{ + parameters: { + shared_preload_libraries: 123, + }, + }`) + + result := reconciler.generatePostgresParameters(ctx, cluster, false) + assert.Assert(t, cmp.Contains(result.Value("shared_preload_libraries"), "123")) + }) + + t.Run("Precedence", func(t *testing.T) { + cluster := v1beta1.NewPostgresCluster() + require.UnmarshalInto(t, &cluster.Spec.Config, `{ + parameters: { + shared_preload_libraries: given, + }, + }`) + + result := reconciler.generatePostgresParameters(ctx, cluster, false) + assert.Equal(t, result.Value("shared_preload_libraries"), "pgaudit,given", + "expected mandatory ahead of specified") + + require.UnmarshalInto(t, &cluster.Spec.Config, `{ + parameters: { + shared_preload_libraries: 'given, citus,other' + }, + }`) + + result = reconciler.generatePostgresParameters(ctx, cluster, false) + assert.Equal(t, result.Value("shared_preload_libraries"), "citus,pgaudit,given, citus,other", + "expected citus in front") + }) + }) +} + func TestGeneratePostgresUserSecret(t *testing.T) { _, tClient := setupKubernetes(t) require.ParallelCapacity(t, 0) @@ -163,7 +406,7 @@ func TestGeneratePostgresUserSecret(t *testing.T) { } // Present when specified. - spec.Databases = []v1beta1.PostgresIdentifier{"db1"} + spec.Databases = []string{"db1"} secret, err = reconciler.generatePostgresUserSecret(cluster, &spec, nil) assert.NilError(t, err) @@ -180,7 +423,7 @@ func TestGeneratePostgresUserSecret(t *testing.T) { } // Only the first in the list. - spec.Databases = []v1beta1.PostgresIdentifier{"first", "asdf"} + spec.Databases = []string{"first", "asdf"} secret, err = reconciler.generatePostgresUserSecret(cluster, &spec, nil) assert.NilError(t, err) @@ -198,9 +441,9 @@ func TestGeneratePostgresUserSecret(t *testing.T) { }) t.Run("PgBouncer", func(t *testing.T) { - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, &cluster.Spec, `{ proxy: { pgBouncer: { port: 10220 } }, - }`), &cluster.Spec)) + }`) secret, err := reconciler.generatePostgresUserSecret(cluster, spec, nil) assert.NilError(t, err) @@ -214,7 +457,7 @@ func TestGeneratePostgresUserSecret(t *testing.T) { // Includes a URI when possible. spec := *spec - spec.Databases = []v1beta1.PostgresIdentifier{"yes", "no"} + spec.Databases = []string{"yes", "no"} secret, err = reconciler.generatePostgresUserSecret(cluster, &spec, nil) assert.NilError(t, err) @@ -250,14 +493,14 @@ func TestReconcilePostgresVolumes(t *testing.T) { t.Cleanup(func() { assert.Check(t, tClient.Delete(ctx, cluster)) }) spec := &v1beta1.PostgresInstanceSetSpec{} - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, spec, `{ name: "some-instance", dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } }, storageClassName: "storage-class-for-data", }, - }`), spec)) + }`) instance := &appsv1.StatefulSet{ObjectMeta: naming.GenerateInstance(cluster, spec)} pvc, err := reconciler.reconcilePostgresDataVolume(ctx, cluster, spec, instance, nil, nil) @@ -282,6 +525,9 @@ volumeMode: Filesystem }) t.Run("DataVolumeSourceClusterWithGoodSnapshot", func(t *testing.T) { + if strings.EqualFold(os.Getenv("USE_EXISTING_CLUSTER"), "true") { + t.Skip("requires mocking of Job conditions") + } cluster := testCluster() ns := setupNamespace(t, tClient) cluster.Namespace = ns.Name @@ -290,14 +536,14 @@ volumeMode: Filesystem t.Cleanup(func() { assert.Check(t, tClient.Delete(ctx, cluster)) }) spec := &v1beta1.PostgresInstanceSetSpec{} - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, spec, `{ name: "some-instance", dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } }, storageClassName: "storage-class-for-data", }, - }`), spec)) + }`) instance := &appsv1.StatefulSet{ObjectMeta: naming.GenerateInstance(cluster, spec)} recorder := events.NewRecorder(t, runtime.Scheme) @@ -392,14 +638,14 @@ volumeMode: Filesystem t.Cleanup(func() { assert.Check(t, tClient.Delete(ctx, cluster)) }) spec := &v1beta1.PostgresInstanceSetSpec{} - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, spec, `{ name: "some-instance", dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } }, storageClassName: "storage-class-for-data", }, - }`), spec)) + }`) instance := &appsv1.StatefulSet{ObjectMeta: naming.GenerateInstance(cluster, spec)} recorder := events.NewRecorder(t, runtime.Scheme) @@ -455,14 +701,14 @@ volumeMode: Filesystem t.Cleanup(func() { assert.Check(t, tClient.Delete(ctx, cluster)) }) spec := &v1beta1.PostgresInstanceSetSpec{} - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, spec, `{ name: "some-instance", dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } }, storageClassName: "storage-class-for-data", }, - }`), spec)) + }`) instance := &appsv1.StatefulSet{ObjectMeta: naming.GenerateInstance(cluster, spec)} observed := &Instance{} @@ -475,13 +721,13 @@ volumeMode: Filesystem t.Run("Specified", func(t *testing.T) { spec := spec.DeepCopy() - assert.NilError(t, yaml.Unmarshal([]byte(`{ + require.UnmarshalInto(t, spec, `{ walVolumeClaimSpec: { accessModes: [ReadWriteMany], resources: { requests: { storage: 2Gi } }, storageClassName: "storage-class-for-wal", }, - }`), spec)) + }`) pvc, err := reconciler.reconcilePostgresWALVolume(ctx, cluster, spec, instance, observed, nil) assert.NilError(t, err) @@ -602,318 +848,6 @@ volumeMode: Filesystem }) } -func TestSetVolumeSize(t *testing.T) { - t.Parallel() - - ctx := context.Background() - cluster := v1beta1.PostgresCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "elephant", - Namespace: "test-namespace", - }, - Spec: v1beta1.PostgresClusterSpec{ - InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ - Name: "some-instance", - Replicas: initialize.Int32(1), - }}, - }, - } - - instance := &appsv1.StatefulSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "elephant-some-instance-wxyz-0", - Namespace: cluster.Namespace, - }} - - setupLogCapture := func(ctx context.Context) (context.Context, *[]string) { - calls := []string{} - testlog := funcr.NewJSON(func(object string) { - calls = append(calls, object) - }, funcr.Options{ - Verbosity: 1, - }) - return logging.NewContext(ctx, testlog), &calls - } - - // helper functions - instanceSetSpec := func(request, limit string) *v1beta1.PostgresInstanceSetSpec { - return &v1beta1.PostgresInstanceSetSpec{ - Name: "some-instance", - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: map[corev1.ResourceName]resource.Quantity{ - corev1.ResourceStorage: resource.MustParse(request), - }, - Limits: map[corev1.ResourceName]resource.Quantity{ - corev1.ResourceStorage: resource.MustParse(limit), - }}}} - } - - desiredStatus := func(request string) v1beta1.PostgresClusterStatus { - desiredMap := make(map[string]string) - desiredMap["elephant-some-instance-wxyz-0"] = request - return v1beta1.PostgresClusterStatus{ - InstanceSets: []v1beta1.PostgresInstanceSetStatus{{ - Name: "some-instance", - DesiredPGDataVolume: desiredMap, - }}} - } - - t.Run("RequestAboveLimit", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} - spec := instanceSetSpec("4Gi", "3Gi") - pvc.Spec = spec.DataVolumeClaimSpec - - reconciler.setVolumeSize(ctx, &cluster, pvc, spec.Name) - - assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` -accessModes: -- ReadWriteOnce -resources: - limits: - storage: 3Gi - requests: - storage: 3Gi -`)) - assert.Equal(t, len(*logs), 0) - assert.Equal(t, len(recorder.Events), 1) - assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) - assert.Equal(t, recorder.Events[0].Reason, "VolumeRequestOverLimit") - assert.Equal(t, recorder.Events[0].Note, "pgData volume request (4Gi) for elephant/some-instance is greater than set limit (3Gi). Limit value will be used.") - }) - - t.Run("NoFeatureGate", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} - spec := instanceSetSpec("1Gi", "3Gi") - - desiredMap := make(map[string]string) - desiredMap["elephant-some-instance-wxyz-0"] = "2Gi" - cluster.Status = v1beta1.PostgresClusterStatus{ - InstanceSets: []v1beta1.PostgresInstanceSetStatus{{ - Name: "some-instance", - DesiredPGDataVolume: desiredMap, - }}, - } - - pvc.Spec = spec.DataVolumeClaimSpec - - reconciler.setVolumeSize(ctx, &cluster, pvc, spec.Name) - - assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` -accessModes: -- ReadWriteOnce -resources: - limits: - storage: 3Gi - requests: - storage: 1Gi - `)) - - assert.Equal(t, len(recorder.Events), 0) - assert.Equal(t, len(*logs), 0) - - // clear status for other tests - cluster.Status = v1beta1.PostgresClusterStatus{} - }) - - t.Run("FeatureEnabled", func(t *testing.T) { - gate := feature.NewGate() - assert.NilError(t, gate.SetFromMap(map[string]bool{ - feature.AutoGrowVolumes: true, - })) - ctx := feature.NewContext(ctx, gate) - - t.Run("StatusNoLimit", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} - spec := &v1beta1.PostgresInstanceSetSpec{ - Name: "some-instance", - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: map[corev1.ResourceName]resource.Quantity{ - corev1.ResourceStorage: resource.MustParse("1Gi"), - }}}} - cluster.Status = desiredStatus("2Gi") - pvc.Spec = spec.DataVolumeClaimSpec - - reconciler.setVolumeSize(ctx, &cluster, pvc, spec.Name) - - assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` -accessModes: -- ReadWriteOnce -resources: - requests: - storage: 1Gi -`)) - assert.Equal(t, len(recorder.Events), 0) - assert.Equal(t, len(*logs), 0) - - // clear status for other tests - cluster.Status = v1beta1.PostgresClusterStatus{} - }) - - t.Run("LimitNoStatus", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} - spec := instanceSetSpec("1Gi", "2Gi") - pvc.Spec = spec.DataVolumeClaimSpec - - reconciler.setVolumeSize(ctx, &cluster, pvc, spec.Name) - - assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` -accessModes: -- ReadWriteOnce -resources: - limits: - storage: 2Gi - requests: - storage: 1Gi -`)) - assert.Equal(t, len(recorder.Events), 0) - assert.Equal(t, len(*logs), 0) - }) - - t.Run("BadStatusWithLimit", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} - spec := instanceSetSpec("1Gi", "3Gi") - cluster.Status = desiredStatus("NotAValidValue") - pvc.Spec = spec.DataVolumeClaimSpec - - reconciler.setVolumeSize(ctx, &cluster, pvc, spec.Name) - - assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` -accessModes: -- ReadWriteOnce -resources: - limits: - storage: 3Gi - requests: - storage: 1Gi -`)) - - assert.Equal(t, len(recorder.Events), 0) - assert.Equal(t, len(*logs), 1) - assert.Assert(t, cmp.Contains((*logs)[0], "Unable to parse volume request: NotAValidValue")) - }) - - t.Run("StatusWithLimit", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} - spec := instanceSetSpec("1Gi", "3Gi") - cluster.Status = desiredStatus("2Gi") - pvc.Spec = spec.DataVolumeClaimSpec - - reconciler.setVolumeSize(ctx, &cluster, pvc, spec.Name) - - assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` -accessModes: -- ReadWriteOnce -resources: - limits: - storage: 3Gi - requests: - storage: 2Gi -`)) - assert.Equal(t, len(recorder.Events), 0) - assert.Equal(t, len(*logs), 0) - }) - - t.Run("StatusWithLimitGrowToLimit", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} - spec := instanceSetSpec("1Gi", "2Gi") - cluster.Status = desiredStatus("2Gi") - pvc.Spec = spec.DataVolumeClaimSpec - - reconciler.setVolumeSize(ctx, &cluster, pvc, spec.Name) - - assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` -accessModes: -- ReadWriteOnce -resources: - limits: - storage: 2Gi - requests: - storage: 2Gi -`)) - - assert.Equal(t, len(*logs), 0) - assert.Equal(t, len(recorder.Events), 1) - assert.Equal(t, recorder.Events[0].Regarding.Name, cluster.Name) - assert.Equal(t, recorder.Events[0].Reason, "VolumeLimitReached") - assert.Equal(t, recorder.Events[0].Note, "pgData volume(s) for elephant/some-instance are at size limit (2Gi).") - }) - - t.Run("DesiredStatusOverLimit", func(t *testing.T) { - recorder := events.NewRecorder(t, runtime.Scheme) - reconciler := &Reconciler{Recorder: recorder} - ctx, logs := setupLogCapture(ctx) - - pvc := &corev1.PersistentVolumeClaim{ObjectMeta: naming.InstancePostgresDataVolume(instance)} - spec := instanceSetSpec("4Gi", "5Gi") - cluster.Status = desiredStatus("10Gi") - pvc.Spec = spec.DataVolumeClaimSpec - - reconciler.setVolumeSize(ctx, &cluster, pvc, spec.Name) - - assert.Assert(t, cmp.MarshalMatches(pvc.Spec, ` -accessModes: -- ReadWriteOnce -resources: - limits: - storage: 5Gi - requests: - storage: 5Gi -`)) - - assert.Equal(t, len(*logs), 0) - assert.Equal(t, len(recorder.Events), 2) - var found1, found2 bool - for _, event := range recorder.Events { - if event.Reason == "VolumeLimitReached" { - found1 = true - assert.Equal(t, event.Regarding.Name, cluster.Name) - assert.Equal(t, event.Note, "pgData volume(s) for elephant/some-instance are at size limit (5Gi).") - } - if event.Reason == "DesiredVolumeAboveLimit" { - found2 = true - assert.Equal(t, event.Regarding.Name, cluster.Name) - assert.Equal(t, event.Note, - "The desired size (10Gi) for the elephant/some-instance pgData volume(s) is greater than the size limit (5Gi).") - } - } - assert.Assert(t, found1 && found2) - }) - - }) -} - func TestReconcileDatabaseInitSQL(t *testing.T) { ctx := context.Background() var called bool @@ -951,7 +885,7 @@ func TestReconcileDatabaseInitSQL(t *testing.T) { Namespace: ns.Name, Name: "pod", Annotations: map[string]string{ - "status": `{"role":"master"}`, + "status": `{"role":"primary"}`, }, }, Status: corev1.PodStatus{ @@ -1072,7 +1006,7 @@ func TestReconcileDatabaseInitSQLConfigMap(t *testing.T) { Namespace: ns.Name, Name: "pod", Annotations: map[string]string{ - "status": `{"role":"master"}`, + "status": `{"role":"primary"}`, }, }, Status: corev1.PodStatus{ diff --git a/internal/controller/postgrescluster/rbac.go b/internal/controller/postgrescluster/rbac.go index 38dd808c44..55b685d1c3 100644 --- a/internal/controller/postgrescluster/rbac.go +++ b/internal/controller/postgrescluster/rbac.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/snapshots.go b/internal/controller/postgrescluster/snapshots.go index 76ad195600..a0fa8c94ec 100644 --- a/internal/controller/postgrescluster/snapshots.go +++ b/internal/controller/postgrescluster/snapshots.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -10,17 +10,18 @@ import ( "strings" "time" + volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" "github.com/pkg/errors" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/controller-runtime/pkg/client" - volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" - "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/kubernetes" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/pgbackrest" "github.com/crunchydata/postgres-operator/internal/postgres" @@ -56,14 +57,11 @@ func (r *Reconciler) reconcileVolumeSnapshots(ctx context.Context, return nil } - // Check if the Kube cluster has VolumeSnapshots installed. If VolumeSnapshots - // are not installed, we need to return early. If user is attempting to use - // VolumeSnapshots, return an error, otherwise return nil. - volumeSnapshotKindExists, err := r.GroupVersionKindExists("snapshot.storage.k8s.io/v1", "VolumeSnapshot") - if err != nil { - return err - } - if !*volumeSnapshotKindExists { + // Return early when VolumeSnapshots are not installed in Kubernetes. + // If user is attempting to use VolumeSnapshots, return an error. + if !kubernetes.Has( + ctx, volumesnapshotv1.SchemeGroupVersion.WithKind("VolumeSnapshot"), + ) { if postgrescluster.Spec.Backups.Snapshots != nil { return errors.New("VolumeSnapshots are not installed/enabled in this Kubernetes cluster; cannot create snapshot.") } else { @@ -102,10 +100,10 @@ func (r *Reconciler) reconcileVolumeSnapshots(ctx context.Context, if snapshotWithLatestError != nil { r.Recorder.Event(postgrescluster, corev1.EventTypeWarning, "VolumeSnapshotError", *snapshotWithLatestError.Status.Error.Message) - for _, snapshot := range snapshots.Items { + for _, snapshot := range snapshots { if snapshot.Status != nil && snapshot.Status.Error != nil && snapshot.Status.Error.Time.Before(snapshotWithLatestError.Status.Error.Time) { - err = r.deleteControlled(ctx, postgrescluster, &snapshot) + err = r.deleteControlled(ctx, postgrescluster, snapshot) if err != nil { return err } @@ -124,7 +122,7 @@ func (r *Reconciler) reconcileVolumeSnapshots(ctx context.Context, // the dedicated pvc. var snapshotForPvcUpdateIdx int snapshotFoundForPvcUpdate := false - for idx, snapshot := range snapshots.Items { + for idx, snapshot := range snapshots { if snapshot.GetAnnotations()[naming.PGBackRestBackupJobCompletion] == pvcUpdateTimeStamp { snapshotForPvcUpdateIdx = idx snapshotFoundForPvcUpdate = true @@ -133,11 +131,11 @@ func (r *Reconciler) reconcileVolumeSnapshots(ctx context.Context, // If a snapshot exists for the latest backup that has been restored into the dedicated pvc // and the snapshot is Ready, delete all other snapshots. - if snapshotFoundForPvcUpdate && snapshots.Items[snapshotForPvcUpdateIdx].Status.ReadyToUse != nil && - *snapshots.Items[snapshotForPvcUpdateIdx].Status.ReadyToUse { - for idx, snapshot := range snapshots.Items { + if snapshotFoundForPvcUpdate && snapshots[snapshotForPvcUpdateIdx].Status.ReadyToUse != nil && + *snapshots[snapshotForPvcUpdateIdx].Status.ReadyToUse { + for idx, snapshot := range snapshots { if idx != snapshotForPvcUpdateIdx { - err = r.deleteControlled(ctx, postgrescluster, &snapshot) + err = r.deleteControlled(ctx, postgrescluster, snapshot) if err != nil { return err } @@ -166,7 +164,7 @@ func (r *Reconciler) reconcileVolumeSnapshots(ctx context.Context, // after a successful backup. func (r *Reconciler) reconcileDedicatedSnapshotVolume( ctx context.Context, cluster *v1beta1.PostgresCluster, - clusterVolumes []corev1.PersistentVolumeClaim, + clusterVolumes []*corev1.PersistentVolumeClaim, ) (*corev1.PersistentVolumeClaim, error) { // If VolumeSnapshots feature gate is disabled, do nothing and return early. @@ -183,10 +181,7 @@ func (r *Reconciler) reconcileDedicatedSnapshotVolume( // If volume already exists, use existing name. Otherwise, generate a name. var pvc *corev1.PersistentVolumeClaim - existingPVCName, err := getPGPVCName(labelMap, clusterVolumes) - if err != nil { - return nil, errors.WithStack(err) - } + existingPVCName := getPVCName(clusterVolumes, labels.SelectorFromSet(labelMap)) if existingPVCName != "" { pvc = &corev1.PersistentVolumeClaim{ObjectMeta: metav1.ObjectMeta{ Namespace: cluster.GetNamespace(), @@ -210,7 +205,7 @@ func (r *Reconciler) reconcileDedicatedSnapshotVolume( // If we've got this far, snapshots are enabled so we should create/update/get // the dedicated snapshot volume - pvc, err = r.createDedicatedSnapshotVolume(ctx, cluster, labelMap, pvc) + pvc, err := r.createDedicatedSnapshotVolume(ctx, cluster, labelMap, pvc) if err != nil { return pvc, err } @@ -317,10 +312,10 @@ func (r *Reconciler) createDedicatedSnapshotVolume(ctx context.Context, return pvc, err } - pvc.Spec = instanceSpec.DataVolumeClaimSpec + pvc.Spec = instanceSpec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() // Set the snapshot volume to the same size as the pgdata volume. The size should scale with auto-grow. - r.setVolumeSize(ctx, cluster, pvc, instanceSpec.Name) + r.setVolumeSize(ctx, cluster, pvc, "pgData", instanceSpec.Name) // Clear any set limit before applying PVC. This is needed to allow the limit // value to change later. @@ -398,7 +393,7 @@ func (r *Reconciler) dedicatedSnapshotVolumeRestore(ctx context.Context, cluster.Spec.ImagePullPolicy, &restoreJob.Spec.Template) - addTMPEmptyDir(&restoreJob.Spec.Template) + AddTMPEmptyDir(&restoreJob.Spec.Template) restoreJob.Annotations[naming.PGBackRestBackupJobCompletion] = backupJob.Status.CompletionTime.Format(time.RFC3339) return errors.WithStack(r.apply(ctx, restoreJob)) @@ -527,16 +522,16 @@ func (r *Reconciler) getLatestCompleteBackupJob(ctx context.Context, // getSnapshotWithLatestError takes a VolumeSnapshotList and returns a pointer to the // snapshot that has most recently had an error. If no snapshot errors exist // then it returns nil. -func getSnapshotWithLatestError(snapshots *volumesnapshotv1.VolumeSnapshotList) *volumesnapshotv1.VolumeSnapshot { +func getSnapshotWithLatestError(snapshots []*volumesnapshotv1.VolumeSnapshot) *volumesnapshotv1.VolumeSnapshot { zeroTime := metav1.NewTime(time.Time{}) - snapshotWithLatestError := volumesnapshotv1.VolumeSnapshot{ + snapshotWithLatestError := &volumesnapshotv1.VolumeSnapshot{ Status: &volumesnapshotv1.VolumeSnapshotStatus{ Error: &volumesnapshotv1.VolumeSnapshotError{ Time: &zeroTime, }, }, } - for _, snapshot := range snapshots.Items { + for _, snapshot := range snapshots { if snapshot.Status != nil && snapshot.Status.Error != nil && snapshotWithLatestError.Status.Error.Time.Before(snapshot.Status.Error.Time) { snapshotWithLatestError = snapshot @@ -547,12 +542,12 @@ func getSnapshotWithLatestError(snapshots *volumesnapshotv1.VolumeSnapshotList) return nil } - return &snapshotWithLatestError + return snapshotWithLatestError } // getSnapshotsForCluster gets all the VolumeSnapshots for a given postgrescluster. func (r *Reconciler) getSnapshotsForCluster(ctx context.Context, cluster *v1beta1.PostgresCluster) ( - *volumesnapshotv1.VolumeSnapshotList, error) { + []*volumesnapshotv1.VolumeSnapshot, error) { selectSnapshots, err := naming.AsSelector(naming.Cluster(cluster.Name)) if err != nil { @@ -565,18 +560,18 @@ func (r *Reconciler) getSnapshotsForCluster(ctx context.Context, cluster *v1beta client.MatchingLabelsSelector{Selector: selectSnapshots}, )) - return snapshots, err + return initialize.Pointers(snapshots.Items...), err } // getLatestReadySnapshot takes a VolumeSnapshotList and returns the latest ready VolumeSnapshot. -func getLatestReadySnapshot(snapshots *volumesnapshotv1.VolumeSnapshotList) *volumesnapshotv1.VolumeSnapshot { +func getLatestReadySnapshot(snapshots []*volumesnapshotv1.VolumeSnapshot) *volumesnapshotv1.VolumeSnapshot { zeroTime := metav1.NewTime(time.Time{}) - latestReadySnapshot := volumesnapshotv1.VolumeSnapshot{ + latestReadySnapshot := &volumesnapshotv1.VolumeSnapshot{ Status: &volumesnapshotv1.VolumeSnapshotStatus{ CreationTime: &zeroTime, }, } - for _, snapshot := range snapshots.Items { + for _, snapshot := range snapshots { if snapshot.Status != nil && snapshot.Status.ReadyToUse != nil && *snapshot.Status.ReadyToUse && latestReadySnapshot.Status.CreationTime.Before(snapshot.Status.CreationTime) { latestReadySnapshot = snapshot @@ -587,17 +582,17 @@ func getLatestReadySnapshot(snapshots *volumesnapshotv1.VolumeSnapshotList) *vol return nil } - return &latestReadySnapshot + return latestReadySnapshot } // deleteSnapshots takes a postgrescluster and a snapshot list and deletes all snapshots // in the list that are controlled by the provided postgrescluster. func (r *Reconciler) deleteSnapshots(ctx context.Context, - postgrescluster *v1beta1.PostgresCluster, snapshots *volumesnapshotv1.VolumeSnapshotList) error { + postgrescluster *v1beta1.PostgresCluster, snapshots []*volumesnapshotv1.VolumeSnapshot) error { - for i := range snapshots.Items { + for i := range snapshots { err := errors.WithStack(client.IgnoreNotFound( - r.deleteControlled(ctx, postgrescluster, &snapshots.Items[i]))) + r.deleteControlled(ctx, postgrescluster, snapshots[i]))) if err != nil { return err } diff --git a/internal/controller/postgrescluster/snapshots_test.go b/internal/controller/postgrescluster/snapshots_test.go index 4c3d987ecd..af5d4d1247 100644 --- a/internal/controller/postgrescluster/snapshots_test.go +++ b/internal/controller/postgrescluster/snapshots_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,10 +6,12 @@ package postgrescluster import ( "context" + "os" + "strings" "testing" "time" - "github.com/pkg/errors" + volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" @@ -17,49 +19,47 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/discovery" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/kubernetes" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/testing/cmp" "github.com/crunchydata/postgres-operator/internal/testing/events" "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" - - volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" ) func TestReconcileVolumeSnapshots(t *testing.T) { ctx := context.Background() - cfg, cc := setupKubernetes(t) + _, cc := setupKubernetes(t) require.ParallelCapacity(t, 1) - discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) - assert.NilError(t, err) recorder := events.NewRecorder(t, runtime.Scheme) r := &Reconciler{ - Client: cc, - Owner: client.FieldOwner(t.Name()), - DiscoveryClient: discoveryClient, - Recorder: recorder, + Client: cc, + Owner: client.FieldOwner(t.Name()), + Recorder: recorder, } ns := setupNamespace(t, cc) - // Enable snapshots feature gate + // Enable snapshots feature gate and API gate := feature.NewGate() assert.NilError(t, gate.SetFromMap(map[string]bool{ feature.VolumeSnapshots: true, })) ctx = feature.NewContext(ctx, gate) + ctx = kubernetes.NewAPIContext(ctx, kubernetes.NewAPISet( + volumesnapshotv1.SchemeGroupVersion.WithKind("VolumeSnapshot"), + )) t.Run("SnapshotsDisabledDeleteSnapshots", func(t *testing.T) { // Create cluster (without snapshots spec) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" assert.NilError(t, r.Client.Create(ctx, cluster)) t.Cleanup(func() { assert.Check(t, r.Client.Delete(ctx, cluster)) }) @@ -72,34 +72,29 @@ func TestReconcileVolumeSnapshots(t *testing.T) { volumeSnapshotClassName := "my-snapshotclass" snapshot, err := r.generateVolumeSnapshot(cluster, *pvc, volumeSnapshotClassName) assert.NilError(t, err) - err = errors.WithStack(r.apply(ctx, snapshot)) - assert.NilError(t, err) + assert.NilError(t, r.Client.Create(ctx, snapshot)) // Get all snapshots for this cluster and assert 1 exists selectSnapshots, err := naming.AsSelector(naming.Cluster(cluster.Name)) assert.NilError(t, err) snapshots := &volumesnapshotv1.VolumeSnapshotList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, snapshots, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectSnapshots}, )) - assert.NilError(t, err) assert.Equal(t, len(snapshots.Items), 1) // Reconcile snapshots - err = r.reconcileVolumeSnapshots(ctx, cluster, pvc) - assert.NilError(t, err) + assert.NilError(t, r.reconcileVolumeSnapshots(ctx, cluster, pvc)) // Get all snapshots for this cluster and assert 0 exist - assert.NilError(t, err) snapshots = &volumesnapshotv1.VolumeSnapshotList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, snapshots, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectSnapshots}, )) - assert.NilError(t, err) assert.Equal(t, len(snapshots.Items), 0) }) @@ -131,8 +126,7 @@ func TestReconcileVolumeSnapshots(t *testing.T) { } // Reconcile - err = r.reconcileVolumeSnapshots(ctx, cluster, pvc) - assert.NilError(t, err) + assert.NilError(t, r.reconcileVolumeSnapshots(ctx, cluster, pvc)) // Assert warning event was created and has expected attributes if assert.Check(t, len(recorder.Events) > 0) { @@ -173,23 +167,24 @@ func TestReconcileVolumeSnapshots(t *testing.T) { } // Reconcile - err = r.reconcileVolumeSnapshots(ctx, cluster, pvc) - assert.NilError(t, err) + assert.NilError(t, r.reconcileVolumeSnapshots(ctx, cluster, pvc)) // Assert no snapshots exist selectSnapshots, err := naming.AsSelector(naming.Cluster(cluster.Name)) assert.NilError(t, err) snapshots := &volumesnapshotv1.VolumeSnapshotList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, snapshots, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectSnapshots}, )) - assert.NilError(t, err) assert.Equal(t, len(snapshots.Items), 0) }) t.Run("SnapshotsEnabledReadySnapshotsExist", func(t *testing.T) { + if strings.EqualFold(os.Getenv("USE_EXISTING_CLUSTER"), "true") { + t.Skip("requires mocking of Job conditions") + } // Create a volume snapshot class volumeSnapshotClassName := "my-snapshotclass" volumeSnapshotClass := &volumesnapshotv1.VolumeSnapshotClass{ @@ -204,7 +199,7 @@ func TestReconcileVolumeSnapshots(t *testing.T) { // Create a cluster with snapshots enabled cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" cluster.Spec.Backups.Snapshots = &v1beta1.VolumeSnapshots{ VolumeSnapshotClassName: volumeSnapshotClassName, } @@ -244,18 +239,15 @@ func TestReconcileVolumeSnapshots(t *testing.T) { }, }, } - err := errors.WithStack(r.setControllerReference(cluster, snapshot1)) - assert.NilError(t, err) - err = r.apply(ctx, snapshot1) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(cluster, snapshot1)) + assert.NilError(t, r.Client.Create(ctx, snapshot1)) // Update snapshot status truePtr := initialize.Bool(true) snapshot1.Status = &volumesnapshotv1.VolumeSnapshotStatus{ ReadyToUse: truePtr, } - err = r.Client.Status().Update(ctx, snapshot1) - assert.NilError(t, err) + assert.NilError(t, r.Client.Status().Update(ctx, snapshot1)) // Create second snapshot with different annotation value snapshot2 := &volumesnapshotv1.VolumeSnapshot{ @@ -279,38 +271,32 @@ func TestReconcileVolumeSnapshots(t *testing.T) { }, }, } - err = errors.WithStack(r.setControllerReference(cluster, snapshot2)) - assert.NilError(t, err) - err = r.apply(ctx, snapshot2) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(cluster, snapshot2)) + assert.NilError(t, r.Client.Create(ctx, snapshot2)) // Update second snapshot's status snapshot2.Status = &volumesnapshotv1.VolumeSnapshotStatus{ ReadyToUse: truePtr, } - err = r.Client.Status().Update(ctx, snapshot2) - assert.NilError(t, err) + assert.NilError(t, r.Client.Status().Update(ctx, snapshot2)) // Reconcile - err = r.reconcileVolumeSnapshots(ctx, cluster, pvc) - assert.NilError(t, err) + assert.NilError(t, r.reconcileVolumeSnapshots(ctx, cluster, pvc)) // Assert first snapshot exists and second snapshot was deleted selectSnapshots, err := naming.AsSelector(naming.Cluster(cluster.Name)) assert.NilError(t, err) snapshots := &volumesnapshotv1.VolumeSnapshotList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, snapshots, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectSnapshots}, )) - assert.NilError(t, err) assert.Equal(t, len(snapshots.Items), 1) assert.Equal(t, snapshots.Items[0].Name, "first-snapshot") // Cleanup - err = r.deleteControlled(ctx, cluster, snapshot1) - assert.NilError(t, err) + assert.NilError(t, r.deleteControlled(ctx, cluster, snapshot1)) }) t.Run("SnapshotsEnabledCreateSnapshot", func(t *testing.T) { @@ -328,7 +314,7 @@ func TestReconcileVolumeSnapshots(t *testing.T) { // Create a cluster with snapshots enabled cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" cluster.Spec.Backups.Snapshots = &v1beta1.VolumeSnapshots{ VolumeSnapshotClassName: volumeSnapshotClassName, } @@ -347,19 +333,17 @@ func TestReconcileVolumeSnapshots(t *testing.T) { } // Reconcile - err = r.reconcileVolumeSnapshots(ctx, cluster, pvc) - assert.NilError(t, err) + assert.NilError(t, r.reconcileVolumeSnapshots(ctx, cluster, pvc)) // Assert that a snapshot was created selectSnapshots, err := naming.AsSelector(naming.Cluster(cluster.Name)) assert.NilError(t, err) snapshots := &volumesnapshotv1.VolumeSnapshotList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, snapshots, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectSnapshots}, )) - assert.NilError(t, err) assert.Equal(t, len(snapshots.Items), 1) assert.Equal(t, snapshots.Items[0].Annotations[naming.PGBackRestBackupJobCompletion], "another-backup-timestamp") @@ -368,16 +352,13 @@ func TestReconcileVolumeSnapshots(t *testing.T) { func TestReconcileDedicatedSnapshotVolume(t *testing.T) { ctx := context.Background() - cfg, cc := setupKubernetes(t) - discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) - assert.NilError(t, err) + _, cc := setupKubernetes(t) recorder := events.NewRecorder(t, runtime.Scheme) r := &Reconciler{ - Client: cc, - Owner: client.FieldOwner(t.Name()), - DiscoveryClient: discoveryClient, - Recorder: recorder, + Client: cc, + Owner: client.FieldOwner(t.Name()), + Recorder: recorder, } // Enable snapshots feature gate @@ -392,7 +373,7 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { ns := setupNamespace(t, cc) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" assert.NilError(t, r.Client.Create(ctx, cluster)) t.Cleanup(func() { assert.Check(t, r.Client.Delete(ctx, cluster)) }) @@ -411,27 +392,25 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { naming.LabelData: naming.DataPostgres, }, }, - Spec: testVolumeClaimSpec(), } - err = errors.WithStack(r.setControllerReference(cluster, pvc)) - assert.NilError(t, err) - err = r.apply(ctx, pvc) - assert.NilError(t, err) + spec := testVolumeClaimSpec() + pvc.Spec = spec.AsPersistentVolumeClaimSpec() + assert.NilError(t, r.setControllerReference(cluster, pvc)) + assert.NilError(t, r.Client.Create(ctx, pvc)) // Assert that the pvc was created selectPvcs, err := naming.AsSelector(naming.Cluster(cluster.Name)) assert.NilError(t, err) pvcs := &corev1.PersistentVolumeClaimList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, pvcs, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectPvcs}, )) - assert.NilError(t, err) assert.Equal(t, len(pvcs.Items), 1) // Create volumes for reconcile - clusterVolumes := []corev1.PersistentVolumeClaim{*pvc} + clusterVolumes := []*corev1.PersistentVolumeClaim{pvc} // Reconcile returned, err := r.reconcileDedicatedSnapshotVolume(ctx, cluster, clusterVolumes) @@ -452,7 +431,7 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { ns := setupNamespace(t, cc) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" cluster.Spec.Backups.Snapshots = &v1beta1.VolumeSnapshots{ VolumeSnapshotClassName: "my-snapshotclass", } @@ -460,7 +439,7 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { t.Cleanup(func() { assert.Check(t, r.Client.Delete(ctx, cluster)) }) // Create volumes for reconcile - clusterVolumes := []corev1.PersistentVolumeClaim{} + clusterVolumes := []*corev1.PersistentVolumeClaim{} // Reconcile pvc, err := r.reconcileDedicatedSnapshotVolume(ctx, cluster, clusterVolumes) @@ -471,21 +450,23 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { selectPvcs, err := naming.AsSelector(naming.Cluster(cluster.Name)) assert.NilError(t, err) pvcs := &corev1.PersistentVolumeClaimList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, pvcs, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectPvcs}, )) - assert.NilError(t, err) assert.Equal(t, len(pvcs.Items), 1) }) t.Run("SnapshotsEnabledBackupExistsCreateRestore", func(t *testing.T) { + if strings.EqualFold(os.Getenv("USE_EXISTING_CLUSTER"), "true") { + t.Skip("requires mocking of Job conditions") + } // Create cluster with snapshots enabled ns := setupNamespace(t, cc) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" cluster.Spec.Backups.Snapshots = &v1beta1.VolumeSnapshots{ VolumeSnapshotClassName: "my-snapshotclass", } @@ -494,23 +475,18 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { // Create successful backup job backupJob := testBackupJob(cluster) - err = errors.WithStack(r.setControllerReference(cluster, backupJob)) - assert.NilError(t, err) - err = r.apply(ctx, backupJob) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(cluster, backupJob)) + assert.NilError(t, r.Client.Create(ctx, backupJob)) currentTime := metav1.Now() - backupJob.Status = batchv1.JobStatus{ - Succeeded: 1, - CompletionTime: ¤tTime, - } - err = r.Client.Status().Update(ctx, backupJob) - assert.NilError(t, err) + startTime := metav1.NewTime(currentTime.AddDate(0, 0, -1)) + backupJob.Status = succeededJobStatus(startTime, currentTime) + assert.NilError(t, r.Client.Status().Update(ctx, backupJob)) // Create instance set and volumes for reconcile sts := &appsv1.StatefulSet{} generateInstanceStatefulSetIntent(ctx, cluster, &cluster.Spec.InstanceSets[0], "pod-service", "service-account", sts, 1) - clusterVolumes := []corev1.PersistentVolumeClaim{} + clusterVolumes := []*corev1.PersistentVolumeClaim{} // Reconcile pvc, err := r.reconcileDedicatedSnapshotVolume(ctx, cluster, clusterVolumes) @@ -521,22 +497,24 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { restoreJobs := &batchv1.JobList{} selectJobs, err := naming.AsSelector(naming.ClusterRestoreJobs(cluster.Name)) assert.NilError(t, err) - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, restoreJobs, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectJobs}, )) - assert.NilError(t, err) assert.Equal(t, len(restoreJobs.Items), 1) assert.Assert(t, restoreJobs.Items[0].Annotations[naming.PGBackRestBackupJobCompletion] != "") }) t.Run("SnapshotsEnabledSuccessfulRestoreExists", func(t *testing.T) { + if strings.EqualFold(os.Getenv("USE_EXISTING_CLUSTER"), "true") { + t.Skip("requires mocking of Job conditions") + } // Create cluster with snapshots enabled ns := setupNamespace(t, cc) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" cluster.Spec.Backups.Snapshots = &v1beta1.VolumeSnapshots{ VolumeSnapshotClassName: "my-snapshotclass", } @@ -545,43 +523,33 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { // Create times for jobs currentTime := metav1.Now() + currentStartTime := metav1.NewTime(currentTime.AddDate(0, 0, -1)) earlierTime := metav1.NewTime(currentTime.AddDate(-1, 0, 0)) + earlierStartTime := metav1.NewTime(earlierTime.AddDate(0, 0, -1)) // Create successful backup job backupJob := testBackupJob(cluster) - err = errors.WithStack(r.setControllerReference(cluster, backupJob)) - assert.NilError(t, err) - err = r.apply(ctx, backupJob) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(cluster, backupJob)) + assert.NilError(t, r.Client.Create(ctx, backupJob)) - backupJob.Status = batchv1.JobStatus{ - Succeeded: 1, - CompletionTime: &earlierTime, - } - err = r.Client.Status().Update(ctx, backupJob) - assert.NilError(t, err) + backupJob.Status = succeededJobStatus(earlierStartTime, earlierTime) + assert.NilError(t, r.Client.Status().Update(ctx, backupJob)) // Create successful restore job restoreJob := testRestoreJob(cluster) restoreJob.Annotations = map[string]string{ naming.PGBackRestBackupJobCompletion: backupJob.Status.CompletionTime.Format(time.RFC3339), } - err = errors.WithStack(r.setControllerReference(cluster, restoreJob)) - assert.NilError(t, err) - err = r.apply(ctx, restoreJob) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(cluster, restoreJob)) + assert.NilError(t, r.Client.Create(ctx, restoreJob)) - restoreJob.Status = batchv1.JobStatus{ - Succeeded: 1, - CompletionTime: ¤tTime, - } - err = r.Client.Status().Update(ctx, restoreJob) - assert.NilError(t, err) + restoreJob.Status = succeededJobStatus(currentStartTime, currentTime) + assert.NilError(t, r.Client.Status().Update(ctx, restoreJob)) // Create instance set and volumes for reconcile sts := &appsv1.StatefulSet{} generateInstanceStatefulSetIntent(ctx, cluster, &cluster.Spec.InstanceSets[0], "pod-service", "service-account", sts, 1) - clusterVolumes := []corev1.PersistentVolumeClaim{} + clusterVolumes := []*corev1.PersistentVolumeClaim{} // Reconcile pvc, err := r.reconcileDedicatedSnapshotVolume(ctx, cluster, clusterVolumes) @@ -592,12 +560,11 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { restoreJobs := &batchv1.JobList{} selectJobs, err := naming.AsSelector(naming.ClusterRestoreJobs(cluster.Name)) assert.NilError(t, err) - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, restoreJobs, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectJobs}, )) - assert.NilError(t, err) assert.Equal(t, len(restoreJobs.Items), 0) // Assert pvc was annotated @@ -605,11 +572,14 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { }) t.Run("SnapshotsEnabledFailedRestoreExists", func(t *testing.T) { + if strings.EqualFold(os.Getenv("USE_EXISTING_CLUSTER"), "true") { + t.Skip("requires mocking of Job conditions") + } // Create cluster with snapshots enabled ns := setupNamespace(t, cc) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" cluster.Spec.Backups.Snapshots = &v1beta1.VolumeSnapshots{ VolumeSnapshotClassName: "my-snapshotclass", } @@ -619,43 +589,34 @@ func TestReconcileDedicatedSnapshotVolume(t *testing.T) { // Create times for jobs currentTime := metav1.Now() earlierTime := metav1.NewTime(currentTime.AddDate(-1, 0, 0)) + startTime := metav1.NewTime(earlierTime.AddDate(0, 0, -1)) // Create successful backup job backupJob := testBackupJob(cluster) - err = errors.WithStack(r.setControllerReference(cluster, backupJob)) - assert.NilError(t, err) - err = r.apply(ctx, backupJob) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(cluster, backupJob)) + assert.NilError(t, r.Client.Create(ctx, backupJob)) - backupJob.Status = batchv1.JobStatus{ - Succeeded: 1, - CompletionTime: &earlierTime, - } - err = r.Client.Status().Update(ctx, backupJob) - assert.NilError(t, err) + backupJob.Status = succeededJobStatus(startTime, earlierTime) + assert.NilError(t, r.Client.Status().Update(ctx, backupJob)) // Create failed restore job restoreJob := testRestoreJob(cluster) restoreJob.Annotations = map[string]string{ naming.PGBackRestBackupJobCompletion: backupJob.Status.CompletionTime.Format(time.RFC3339), } - err = errors.WithStack(r.setControllerReference(cluster, restoreJob)) - assert.NilError(t, err) - err = r.apply(ctx, restoreJob) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(cluster, restoreJob)) + assert.NilError(t, r.Client.Create(ctx, restoreJob)) restoreJob.Status = batchv1.JobStatus{ - Succeeded: 0, - Failed: 1, - CompletionTime: ¤tTime, + Succeeded: 0, + Failed: 1, } - err = r.Client.Status().Update(ctx, restoreJob) - assert.NilError(t, err) + assert.NilError(t, r.Client.Status().Update(ctx, restoreJob)) // Setup instances and volumes for reconcile sts := &appsv1.StatefulSet{} generateInstanceStatefulSetIntent(ctx, cluster, &cluster.Spec.InstanceSets[0], "pod-service", "service-account", sts, 1) - clusterVolumes := []corev1.PersistentVolumeClaim{} + clusterVolumes := []*corev1.PersistentVolumeClaim{} // Reconcile pvc, err := r.reconcileDedicatedSnapshotVolume(ctx, cluster, clusterVolumes) @@ -685,7 +646,7 @@ func TestCreateDedicatedSnapshotVolume(t *testing.T) { ns := setupNamespace(t, cc) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" labelMap := map[string]string{ naming.LabelCluster: cluster.Name, @@ -713,7 +674,7 @@ func TestDedicatedSnapshotVolumeRestore(t *testing.T) { ns := setupNamespace(t, cc) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" pvc := &corev1.PersistentVolumeClaim{ ObjectMeta: metav1.ObjectMeta{ @@ -727,19 +688,17 @@ func TestDedicatedSnapshotVolumeRestore(t *testing.T) { backupJob := testBackupJob(cluster) backupJob.Status.CompletionTime = ¤tTime - err := r.dedicatedSnapshotVolumeRestore(ctx, cluster, pvc, backupJob) - assert.NilError(t, err) + assert.NilError(t, r.dedicatedSnapshotVolumeRestore(ctx, cluster, pvc, backupJob)) // Assert a restore job was created that has the correct annotation jobs := &batchv1.JobList{} selectJobs, err := naming.AsSelector(naming.ClusterRestoreJobs(cluster.Name)) assert.NilError(t, err) - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, jobs, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selectJobs}, )) - assert.NilError(t, err) assert.Equal(t, len(jobs.Items), 1) assert.Equal(t, jobs.Items[0].Annotations[naming.PGBackRestBackupJobCompletion], backupJob.Status.CompletionTime.Format(time.RFC3339)) @@ -828,7 +787,7 @@ func TestGetDedicatedSnapshotVolumeRestoreJob(t *testing.T) { job1 := testRestoreJob(cluster) job1.Namespace = ns.Name - err := r.apply(ctx, job1) + err := r.Client.Create(ctx, job1) assert.NilError(t, err) dsvRestoreJob, err := r.getDedicatedSnapshotVolumeRestoreJob(ctx, cluster) @@ -844,15 +803,14 @@ func TestGetDedicatedSnapshotVolumeRestoreJob(t *testing.T) { naming.PGBackRestBackupJobCompletion: "backup-timestamp", } - err := r.apply(ctx, job2) + err := r.Client.Create(ctx, job2) assert.NilError(t, err) job3 := testRestoreJob(cluster) job3.Name = "restore-job-3" job3.Namespace = ns.Name - err = r.apply(ctx, job3) - assert.NilError(t, err) + assert.NilError(t, r.Client.Create(ctx, job3)) dsvRestoreJob, err := r.getDedicatedSnapshotVolumeRestoreJob(ctx, cluster) assert.NilError(t, err) @@ -864,7 +822,6 @@ func TestGetDedicatedSnapshotVolumeRestoreJob(t *testing.T) { func TestGetLatestCompleteBackupJob(t *testing.T) { ctx := context.Background() _, cc := setupKubernetes(t) - // require.ParallelCapacity(t, 1) r := &Reconciler{ Client: cc, @@ -885,7 +842,7 @@ func TestGetLatestCompleteBackupJob(t *testing.T) { job1 := testBackupJob(cluster) job1.Namespace = ns.Name - err := r.apply(ctx, job1) + err := r.Client.Create(ctx, job1) assert.NilError(t, err) latestCompleteBackupJob, err := r.getLatestCompleteBackupJob(ctx, cluster) @@ -894,7 +851,11 @@ func TestGetLatestCompleteBackupJob(t *testing.T) { }) t.Run("OneCompleteBackupJob", func(t *testing.T) { + if strings.EqualFold(os.Getenv("USE_EXISTING_CLUSTER"), "true") { + t.Skip("requires mocking of Job conditions") + } currentTime := metav1.Now() + currentStartTime := metav1.NewTime(currentTime.AddDate(0, 0, -1)) job1 := testBackupJob(cluster) job1.Namespace = ns.Name @@ -906,19 +867,13 @@ func TestGetLatestCompleteBackupJob(t *testing.T) { job2.Namespace = ns.Name job2.Name = "backup-job-2" - err = r.apply(ctx, job2) - assert.NilError(t, err) + assert.NilError(t, r.Client.Create(ctx, job2)) // Get job1 and update Status. - err = r.Client.Get(ctx, client.ObjectKeyFromObject(job1), job1) - assert.NilError(t, err) + assert.NilError(t, r.Client.Get(ctx, client.ObjectKeyFromObject(job1), job1)) - job1.Status = batchv1.JobStatus{ - Succeeded: 1, - CompletionTime: ¤tTime, - } - err = r.Client.Status().Update(ctx, job1) - assert.NilError(t, err) + job1.Status = succeededJobStatus(currentStartTime, currentTime) + assert.NilError(t, r.Client.Status().Update(ctx, job1)) latestCompleteBackupJob, err := r.getLatestCompleteBackupJob(ctx, cluster) assert.NilError(t, err) @@ -926,8 +881,13 @@ func TestGetLatestCompleteBackupJob(t *testing.T) { }) t.Run("TwoCompleteBackupJobs", func(t *testing.T) { + if strings.EqualFold(os.Getenv("USE_EXISTING_CLUSTER"), "true") { + t.Skip("requires mocking of Job conditions") + } currentTime := metav1.Now() + currentStartTime := metav1.NewTime(currentTime.AddDate(0, 0, -1)) earlierTime := metav1.NewTime(currentTime.AddDate(-1, 0, 0)) + earlierStartTime := metav1.NewTime(earlierTime.AddDate(0, 0, -1)) assert.Check(t, earlierTime.Before(¤tTime)) job1 := testBackupJob(cluster) @@ -940,30 +900,19 @@ func TestGetLatestCompleteBackupJob(t *testing.T) { job2.Namespace = ns.Name job2.Name = "backup-job-2" - err = r.apply(ctx, job2) - assert.NilError(t, err) + assert.NilError(t, r.apply(ctx, job2)) // Get job1 and update Status. - err = r.Client.Get(ctx, client.ObjectKeyFromObject(job1), job1) - assert.NilError(t, err) + assert.NilError(t, r.Client.Get(ctx, client.ObjectKeyFromObject(job1), job1)) - job1.Status = batchv1.JobStatus{ - Succeeded: 1, - CompletionTime: ¤tTime, - } - err = r.Client.Status().Update(ctx, job1) - assert.NilError(t, err) + job1.Status = succeededJobStatus(currentStartTime, currentTime) + assert.NilError(t, r.Client.Status().Update(ctx, job1)) // Get job2 and update Status. - err = r.Client.Get(ctx, client.ObjectKeyFromObject(job2), job2) - assert.NilError(t, err) + assert.NilError(t, r.Client.Get(ctx, client.ObjectKeyFromObject(job2), job2)) - job2.Status = batchv1.JobStatus{ - Succeeded: 1, - CompletionTime: &earlierTime, - } - err = r.Client.Status().Update(ctx, job2) - assert.NilError(t, err) + job2.Status = succeededJobStatus(earlierStartTime, earlierTime) + assert.NilError(t, r.Client.Status().Update(ctx, job2)) latestCompleteBackupJob, err := r.getLatestCompleteBackupJob(ctx, cluster) assert.NilError(t, err) @@ -973,107 +922,99 @@ func TestGetLatestCompleteBackupJob(t *testing.T) { func TestGetSnapshotWithLatestError(t *testing.T) { t.Run("NoSnapshots", func(t *testing.T) { - snapshotList := &volumesnapshotv1.VolumeSnapshotList{} - snapshotWithLatestError := getSnapshotWithLatestError(snapshotList) + snapshots := []*volumesnapshotv1.VolumeSnapshot{} + snapshotWithLatestError := getSnapshotWithLatestError(snapshots) assert.Check(t, snapshotWithLatestError == nil) }) t.Run("NoSnapshotsWithStatus", func(t *testing.T) { - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - {}, - {}, - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + {}, + {}, } - snapshotWithLatestError := getSnapshotWithLatestError(snapshotList) + snapshotWithLatestError := getSnapshotWithLatestError(snapshots) assert.Check(t, snapshotWithLatestError == nil) }) t.Run("NoSnapshotsWithErrors", func(t *testing.T) { - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - { - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - ReadyToUse: initialize.Bool(true), - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + { + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + ReadyToUse: initialize.Bool(true), }, - { - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - ReadyToUse: initialize.Bool(false), - }, + }, + { + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + ReadyToUse: initialize.Bool(false), }, }, } - snapshotWithLatestError := getSnapshotWithLatestError(snapshotList) + snapshotWithLatestError := getSnapshotWithLatestError(snapshots) assert.Check(t, snapshotWithLatestError == nil) }) t.Run("OneSnapshotWithError", func(t *testing.T) { currentTime := metav1.Now() earlierTime := metav1.NewTime(currentTime.AddDate(-1, 0, 0)) - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "good-snapshot", - UID: "the-uid-123", - }, - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - CreationTime: ¤tTime, - ReadyToUse: initialize.Bool(true), - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "good-snapshot", + UID: "the-uid-123", }, - { - ObjectMeta: metav1.ObjectMeta{ - Name: "bad-snapshot", - UID: "the-uid-456", - }, - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - ReadyToUse: initialize.Bool(false), - Error: &volumesnapshotv1.VolumeSnapshotError{ - Time: &earlierTime, - }, + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + CreationTime: ¤tTime, + ReadyToUse: initialize.Bool(true), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "bad-snapshot", + UID: "the-uid-456", + }, + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + ReadyToUse: initialize.Bool(false), + Error: &volumesnapshotv1.VolumeSnapshotError{ + Time: &earlierTime, }, }, }, } - snapshotWithLatestError := getSnapshotWithLatestError(snapshotList) - assert.Equal(t, snapshotWithLatestError.ObjectMeta.Name, "bad-snapshot") + snapshotWithLatestError := getSnapshotWithLatestError(snapshots) + assert.Equal(t, snapshotWithLatestError.Name, "bad-snapshot") }) t.Run("TwoSnapshotsWithErrors", func(t *testing.T) { currentTime := metav1.Now() earlierTime := metav1.NewTime(currentTime.AddDate(-1, 0, 0)) - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "first-bad-snapshot", - UID: "the-uid-123", - }, - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - ReadyToUse: initialize.Bool(false), - Error: &volumesnapshotv1.VolumeSnapshotError{ - Time: &earlierTime, - }, - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "first-bad-snapshot", + UID: "the-uid-123", }, - { - ObjectMeta: metav1.ObjectMeta{ - Name: "second-bad-snapshot", - UID: "the-uid-456", + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + ReadyToUse: initialize.Bool(false), + Error: &volumesnapshotv1.VolumeSnapshotError{ + Time: &earlierTime, }, - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - ReadyToUse: initialize.Bool(false), - Error: &volumesnapshotv1.VolumeSnapshotError{ - Time: ¤tTime, - }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "second-bad-snapshot", + UID: "the-uid-456", + }, + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + ReadyToUse: initialize.Bool(false), + Error: &volumesnapshotv1.VolumeSnapshotError{ + Time: ¤tTime, }, }, }, } - snapshotWithLatestError := getSnapshotWithLatestError(snapshotList) - assert.Equal(t, snapshotWithLatestError.ObjectMeta.Name, "second-bad-snapshot") + snapshotWithLatestError := getSnapshotWithLatestError(snapshots) + assert.Equal(t, snapshotWithLatestError.Name, "second-bad-snapshot") }) } @@ -1094,7 +1035,7 @@ func TestGetSnapshotsForCluster(t *testing.T) { t.Run("NoSnapshots", func(t *testing.T) { snapshots, err := r.getSnapshotsForCluster(ctx, cluster) assert.NilError(t, err) - assert.Equal(t, len(snapshots.Items), 0) + assert.Equal(t, len(snapshots), 0) }) t.Run("NoSnapshotsForCluster", func(t *testing.T) { @@ -1113,12 +1054,11 @@ func TestGetSnapshotsForCluster(t *testing.T) { } snapshot.Spec.Source.PersistentVolumeClaimName = initialize.String("some-pvc-name") snapshot.Spec.VolumeSnapshotClassName = initialize.String("some-class-name") - err := r.apply(ctx, snapshot) - assert.NilError(t, err) + assert.NilError(t, r.Client.Create(ctx, snapshot)) snapshots, err := r.getSnapshotsForCluster(ctx, cluster) assert.NilError(t, err) - assert.Equal(t, len(snapshots.Items), 0) + assert.Equal(t, len(snapshots), 0) }) t.Run("OneSnapshotForCluster", func(t *testing.T) { @@ -1155,13 +1095,12 @@ func TestGetSnapshotsForCluster(t *testing.T) { } snapshot2.Spec.Source.PersistentVolumeClaimName = initialize.String("another-pvc-name") snapshot2.Spec.VolumeSnapshotClassName = initialize.String("another-class-name") - err = r.apply(ctx, snapshot2) - assert.NilError(t, err) + assert.NilError(t, r.Client.Create(ctx, snapshot2)) snapshots, err := r.getSnapshotsForCluster(ctx, cluster) assert.NilError(t, err) - assert.Equal(t, len(snapshots.Items), 1) - assert.Equal(t, snapshots.Items[0].Name, "another-snapshot") + assert.Equal(t, len(snapshots), 1) + assert.Equal(t, snapshots[0].Name, "another-snapshot") }) t.Run("TwoSnapshotsForCluster", func(t *testing.T) { @@ -1198,137 +1137,125 @@ func TestGetSnapshotsForCluster(t *testing.T) { } snapshot2.Spec.Source.PersistentVolumeClaimName = initialize.String("another-pvc-name") snapshot2.Spec.VolumeSnapshotClassName = initialize.String("another-class-name") - err = r.apply(ctx, snapshot2) - assert.NilError(t, err) + assert.NilError(t, r.apply(ctx, snapshot2)) snapshots, err := r.getSnapshotsForCluster(ctx, cluster) assert.NilError(t, err) - assert.Equal(t, len(snapshots.Items), 2) + assert.Equal(t, len(snapshots), 2) }) } func TestGetLatestReadySnapshot(t *testing.T) { t.Run("NoSnapshots", func(t *testing.T) { - snapshotList := &volumesnapshotv1.VolumeSnapshotList{} - latestReadySnapshot := getLatestReadySnapshot(snapshotList) + snapshots := []*volumesnapshotv1.VolumeSnapshot{} + latestReadySnapshot := getLatestReadySnapshot(snapshots) assert.Assert(t, latestReadySnapshot == nil) }) t.Run("NoSnapshotsWithStatus", func(t *testing.T) { - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - {}, - {}, - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + {}, + {}, } - latestReadySnapshot := getLatestReadySnapshot(snapshotList) + latestReadySnapshot := getLatestReadySnapshot(snapshots) assert.Assert(t, latestReadySnapshot == nil) }) t.Run("NoReadySnapshots", func(t *testing.T) { - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - { - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - ReadyToUse: initialize.Bool(false), - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + { + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + ReadyToUse: initialize.Bool(false), }, - { - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - ReadyToUse: initialize.Bool(false), - }, + }, + { + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + ReadyToUse: initialize.Bool(false), }, }, } - latestReadySnapshot := getLatestReadySnapshot(snapshotList) + latestReadySnapshot := getLatestReadySnapshot(snapshots) assert.Assert(t, latestReadySnapshot == nil) }) t.Run("OneReadySnapshot", func(t *testing.T) { currentTime := metav1.Now() earlierTime := metav1.NewTime(currentTime.AddDate(-1, 0, 0)) - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "good-snapshot", - UID: "the-uid-123", - }, - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - CreationTime: &earlierTime, - ReadyToUse: initialize.Bool(true), - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "good-snapshot", + UID: "the-uid-123", }, - { - ObjectMeta: metav1.ObjectMeta{ - Name: "bad-snapshot", - UID: "the-uid-456", - }, - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - CreationTime: ¤tTime, - ReadyToUse: initialize.Bool(false), - }, + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + CreationTime: &earlierTime, + ReadyToUse: initialize.Bool(true), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "bad-snapshot", + UID: "the-uid-456", + }, + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + CreationTime: ¤tTime, + ReadyToUse: initialize.Bool(false), }, }, } - latestReadySnapshot := getLatestReadySnapshot(snapshotList) - assert.Equal(t, latestReadySnapshot.ObjectMeta.Name, "good-snapshot") + latestReadySnapshot := getLatestReadySnapshot(snapshots) + assert.Equal(t, latestReadySnapshot.Name, "good-snapshot") }) t.Run("TwoReadySnapshots", func(t *testing.T) { currentTime := metav1.Now() earlierTime := metav1.NewTime(currentTime.AddDate(-1, 0, 0)) - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "first-good-snapshot", - UID: "the-uid-123", - }, - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - CreationTime: &earlierTime, - ReadyToUse: initialize.Bool(true), - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "first-good-snapshot", + UID: "the-uid-123", }, - { - ObjectMeta: metav1.ObjectMeta{ - Name: "second-good-snapshot", - UID: "the-uid-456", - }, - Status: &volumesnapshotv1.VolumeSnapshotStatus{ - CreationTime: ¤tTime, - ReadyToUse: initialize.Bool(true), - }, + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + CreationTime: &earlierTime, + ReadyToUse: initialize.Bool(true), + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "second-good-snapshot", + UID: "the-uid-456", + }, + Status: &volumesnapshotv1.VolumeSnapshotStatus{ + CreationTime: ¤tTime, + ReadyToUse: initialize.Bool(true), }, }, } - latestReadySnapshot := getLatestReadySnapshot(snapshotList) - assert.Equal(t, latestReadySnapshot.ObjectMeta.Name, "second-good-snapshot") + latestReadySnapshot := getLatestReadySnapshot(snapshots) + assert.Equal(t, latestReadySnapshot.Name, "second-good-snapshot") }) } func TestDeleteSnapshots(t *testing.T) { ctx := context.Background() - cfg, cc := setupKubernetes(t) - discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) - assert.NilError(t, err) + _, cc := setupKubernetes(t) r := &Reconciler{ - Client: cc, - Owner: client.FieldOwner(t.Name()), - DiscoveryClient: discoveryClient, + Client: cc, + Owner: client.FieldOwner(t.Name()), } ns := setupNamespace(t, cc) cluster := testCluster() cluster.Namespace = ns.Name - cluster.ObjectMeta.UID = "the-uid-123" + cluster.UID = "the-uid-123" assert.NilError(t, r.Client.Create(ctx, cluster)) rhinoCluster := testCluster() rhinoCluster.Name = "rhino" rhinoCluster.Namespace = ns.Name - rhinoCluster.ObjectMeta.UID = "the-uid-456" + rhinoCluster.UID = "the-uid-456" assert.NilError(t, r.Client.Create(ctx, rhinoCluster)) t.Cleanup(func() { @@ -1337,8 +1264,8 @@ func TestDeleteSnapshots(t *testing.T) { }) t.Run("NoSnapshots", func(t *testing.T) { - snapshotList := &volumesnapshotv1.VolumeSnapshotList{} - err := r.deleteSnapshots(ctx, cluster, snapshotList) + snapshots := []*volumesnapshotv1.VolumeSnapshot{} + err := r.deleteSnapshots(ctx, cluster, snapshots) assert.NilError(t, err) }) @@ -1359,24 +1286,18 @@ func TestDeleteSnapshots(t *testing.T) { }, }, } - err := errors.WithStack(r.setControllerReference(rhinoCluster, snapshot1)) - assert.NilError(t, err) - err = r.apply(ctx, snapshot1) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(rhinoCluster, snapshot1)) + assert.NilError(t, r.Client.Create(ctx, snapshot1)) - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - *snapshot1, - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + snapshot1, } - err = r.deleteSnapshots(ctx, cluster, snapshotList) - assert.NilError(t, err) + assert.NilError(t, r.deleteSnapshots(ctx, cluster, snapshots)) existingSnapshots := &volumesnapshotv1.VolumeSnapshotList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, existingSnapshots, client.InNamespace(ns.Namespace), )) - assert.NilError(t, err) assert.Equal(t, len(existingSnapshots.Items), 1) }) @@ -1397,10 +1318,8 @@ func TestDeleteSnapshots(t *testing.T) { }, }, } - err := errors.WithStack(r.setControllerReference(rhinoCluster, snapshot1)) - assert.NilError(t, err) - err = r.apply(ctx, snapshot1) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(rhinoCluster, snapshot1)) + assert.NilError(t, r.apply(ctx, snapshot1)) snapshot2 := &volumesnapshotv1.VolumeSnapshot{ TypeMeta: metav1.TypeMeta{ @@ -1417,24 +1336,18 @@ func TestDeleteSnapshots(t *testing.T) { }, }, } - err = errors.WithStack(r.setControllerReference(cluster, snapshot2)) - assert.NilError(t, err) - err = r.apply(ctx, snapshot2) - assert.NilError(t, err) + assert.NilError(t, r.setControllerReference(cluster, snapshot2)) + assert.NilError(t, r.Client.Create(ctx, snapshot2)) - snapshotList := &volumesnapshotv1.VolumeSnapshotList{ - Items: []volumesnapshotv1.VolumeSnapshot{ - *snapshot1, *snapshot2, - }, + snapshots := []*volumesnapshotv1.VolumeSnapshot{ + snapshot1, snapshot2, } - err = r.deleteSnapshots(ctx, cluster, snapshotList) - assert.NilError(t, err) + assert.NilError(t, r.deleteSnapshots(ctx, cluster, snapshots)) existingSnapshots := &volumesnapshotv1.VolumeSnapshotList{} - err = errors.WithStack( + assert.NilError(t, r.Client.List(ctx, existingSnapshots, client.InNamespace(ns.Namespace), )) - assert.NilError(t, err) assert.Equal(t, len(existingSnapshots.Items), 1) assert.Equal(t, existingSnapshots.Items[0].Name, "first-snapshot") }) @@ -1474,3 +1387,21 @@ func TestClusterUsingTablespaces(t *testing.T) { assert.Assert(t, clusterUsingTablespaces(ctx, cluster)) }) } + +func succeededJobStatus(startTime, completionTime metav1.Time) batchv1.JobStatus { + return batchv1.JobStatus{ + Succeeded: 1, + StartTime: &startTime, + CompletionTime: &completionTime, + Conditions: []batchv1.JobCondition{ + { + Type: batchv1.JobSuccessCriteriaMet, + Status: corev1.ConditionTrue, + }, + { + Type: batchv1.JobComplete, + Status: corev1.ConditionTrue, + }, + }, + } +} diff --git a/internal/controller/postgrescluster/suite_test.go b/internal/controller/postgrescluster/suite_test.go index 2a0e3d76ec..ffb9d6f1eb 100644 --- a/internal/controller/postgrescluster/suite_test.go +++ b/internal/controller/postgrescluster/suite_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,7 +7,6 @@ package postgrescluster import ( "context" "os" - "path/filepath" "strings" "testing" @@ -15,24 +14,19 @@ import ( . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/util/version" "k8s.io/client-go/discovery" - - // Google Kubernetes Engine / Google Cloud Platform authentication provider - _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" - "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/logging" + "github.com/crunchydata/postgres-operator/internal/testing/require" ) var suite struct { Client client.Client Config *rest.Config - Environment *envtest.Environment ServerVersion *version.Version Manager manager.Manager @@ -53,21 +47,7 @@ var _ = BeforeSuite(func() { log.SetLogger(logging.FromContext(context.Background())) By("bootstrapping test environment") - suite.Environment = &envtest.Environment{ - CRDDirectoryPaths: []string{ - filepath.Join("..", "..", "..", "config", "crd", "bases"), - filepath.Join("..", "..", "..", "hack", "tools", "external-snapshotter", "client", "config", "crd"), - }, - } - - _, err := suite.Environment.Start() - Expect(err).ToNot(HaveOccurred()) - - DeferCleanup(suite.Environment.Stop) - - suite.Config = suite.Environment.Config - suite.Client, err = client.New(suite.Config, client.Options{Scheme: runtime.Scheme}) - Expect(err).ToNot(HaveOccurred()) + suite.Config, suite.Client = require.Kubernetes2(GinkgoT()) dc, err := discovery.NewDiscoveryClientForConfig(suite.Config) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/controller/postgrescluster/topology.go b/internal/controller/postgrescluster/topology.go index 58778be907..9f28739267 100644 --- a/internal/controller/postgrescluster/topology.go +++ b/internal/controller/postgrescluster/topology.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/topology_test.go b/internal/controller/postgrescluster/topology_test.go index 40c8c0dd7f..ec5d1fe137 100644 --- a/internal/controller/postgrescluster/topology_test.go +++ b/internal/controller/postgrescluster/topology_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/postgrescluster/util.go b/internal/controller/postgrescluster/util.go index 25120ab574..2fc849956c 100644 --- a/internal/controller/postgrescluster/util.go +++ b/internal/controller/postgrescluster/util.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,9 +13,11 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/apimachinery/pkg/util/sets" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) var tmpDirSizeLimit = resource.MustParse("16Mi") @@ -134,13 +136,13 @@ func addDevSHM(template *corev1.PodTemplateSpec) { } } -// addTMPEmptyDir adds a "tmp" EmptyDir volume to the provided Pod template, while then also adding a +// AddTMPEmptyDir adds a "tmp" EmptyDir volume to the provided Pod template, while then also adding a // volume mount at /tmp for all containers defined within the Pod template // The '/tmp' directory is currently utilized for the following: // - As the pgBackRest lock directory (this is the default lock location for pgBackRest) // - The location where the replication client certificates can be loaded with the proper // permissions set -func addTMPEmptyDir(template *corev1.PodTemplateSpec) { +func AddTMPEmptyDir(template *corev1.PodTemplateSpec) { template.Spec.Volumes = append(template.Spec.Volumes, corev1.Volume{ Name: "tmp", @@ -285,3 +287,81 @@ func safeHash32(content func(w io.Writer) error) (string, error) { } return rand.SafeEncodeString(fmt.Sprint(hash.Sum32())), nil } + +// AdditionalVolumeMount returns the name and mount path of the additional volume. +func AdditionalVolumeMount(name string, readOnly bool) corev1.VolumeMount { + return corev1.VolumeMount{ + Name: fmt.Sprintf("volumes-%s", name), + MountPath: "/volumes/" + name, + ReadOnly: readOnly, + } +} + +// addAdditionalVolumesToSpecifiedContainers adds additional volumes to the specified +// containers in the specified pod +// addAdditionalVolumesToSpecifiedContainers adds the volumes to the pod +// as `volumes-` +// and adds the directory to the path `/volumes/` +func addAdditionalVolumesToSpecifiedContainers(template *corev1.PodTemplateSpec, + additionalVolumes []v1beta1.AdditionalVolume) []string { + + missingContainers := []string{} + for _, additionalVolumeRequest := range additionalVolumes { + + additionalVolumeMount := AdditionalVolumeMount( + additionalVolumeRequest.Name, + additionalVolumeRequest.ReadOnly, + ) + + additionalVolume := corev1.Volume{ + Name: additionalVolumeMount.Name, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: additionalVolumeRequest.ClaimName, + ReadOnly: additionalVolumeMount.ReadOnly, + }, + }, + } + + // Create a set of all the requested containers, + // then in the loops below when we attach the volume to a container, + // we can safely remove that container name from the set. + // This gives us a way to track the containers that are requested but not found. + // This relies on `containers` and `initContainers` together being unique. + // - https://github.com/kubernetes/api/blob/b40c1cacbb902b21a7e0c7bf0967321860c1a632/core/v1/types.go#L3895C27-L3896C33 + names := sets.New(additionalVolumeRequest.Containers...) + allContainers := false + // If the containers list is omitted, we add the volume to all containers + if additionalVolumeRequest.Containers == nil { + allContainers = true + } + + for i := range template.Spec.Containers { + if allContainers || names.Has(template.Spec.Containers[i].Name) { + template.Spec.Containers[i].VolumeMounts = append( + template.Spec.Containers[i].VolumeMounts, + additionalVolumeMount) + + names.Delete(template.Spec.Containers[i].Name) + } + } + + for i := range template.Spec.InitContainers { + if allContainers || names.Has(template.Spec.InitContainers[i].Name) { + template.Spec.InitContainers[i].VolumeMounts = append( + template.Spec.InitContainers[i].VolumeMounts, + additionalVolumeMount) + + names.Delete(template.Spec.InitContainers[i].Name) + + } + } + + missingContainers = append(missingContainers, names.UnsortedList()...) + + template.Spec.Volumes = append( + template.Spec.Volumes, + additionalVolume) + } + return missingContainers +} diff --git a/internal/controller/postgrescluster/util_test.go b/internal/controller/postgrescluster/util_test.go index 51a32f1e85..0dde296aef 100644 --- a/internal/controller/postgrescluster/util_test.go +++ b/internal/controller/postgrescluster/util_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -16,6 +16,7 @@ import ( "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) func TestSafeHash32(t *testing.T) { @@ -79,7 +80,7 @@ func TestAddDevSHM(t *testing.T) { // check there is an empty dir mounted under the dshm volume for _, v := range template.Spec.Volumes { - if v.Name == "dshm" && v.VolumeSource.EmptyDir != nil && v.VolumeSource.EmptyDir.Medium == corev1.StorageMediumMemory { + if v.Name == "dshm" && v.EmptyDir != nil && v.EmptyDir.Medium == corev1.StorageMediumMemory { found = true break } @@ -221,15 +222,14 @@ func TestAddNSSWrapper(t *testing.T) { // Each container that requires the nss_wrapper envs should be updated var actualUpdatedContainerCount int for i, c := range template.Spec.Containers { - if c.Name == naming.ContainerDatabase || - c.Name == naming.PGBackRestRepoContainerName || - c.Name == naming.PGBackRestRestoreContainerName { + switch c.Name { + case naming.ContainerDatabase, naming.PGBackRestRepoContainerName, naming.PGBackRestRestoreContainerName: assert.DeepEqual(t, expectedEnv, c.Env) actualUpdatedContainerCount++ - } else if c.Name == "pgadmin" { + case "pgadmin": assert.DeepEqual(t, expectedPGAdminEnv, c.Env) actualUpdatedContainerCount++ - } else { + default: assert.DeepEqual(t, beforeAddNSS[i], c) } } @@ -379,3 +379,271 @@ func TestJobFailed(t *testing.T) { }) } } + +func TestAddAdditionalVolumesToSpecifiedContainers(t *testing.T) { + + podTemplate := &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + {Name: "startup"}, + {Name: "config"}, + }, + Containers: []corev1.Container{ + {Name: "database"}, + {Name: "other"}, + }}} + + testCases := []struct { + tcName string + additionalVolumes []v1beta1.AdditionalVolume + expectedContainers string + expectedInitContainers string + expectedVolumes string + expectedMissing []string + }{{ + tcName: "all", + additionalVolumes: []v1beta1.AdditionalVolume{{ + ClaimName: "required", + Name: "required", + }}, + expectedContainers: `- name: database + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required +- name: other + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required`, + expectedInitContainers: `- name: startup + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required +- name: config + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required`, + expectedVolumes: `- name: volumes-required + persistentVolumeClaim: + claimName: required`, + expectedMissing: []string{}, + }, { + tcName: "multiple additional volumes", + additionalVolumes: []v1beta1.AdditionalVolume{{ + ClaimName: "required", + Name: "required", + }, { + ClaimName: "also", + Name: "other", + }}, + expectedContainers: `- name: database + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + - mountPath: /volumes/other + name: volumes-other +- name: other + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + - mountPath: /volumes/other + name: volumes-other`, + expectedInitContainers: `- name: startup + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + - mountPath: /volumes/other + name: volumes-other +- name: config + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + - mountPath: /volumes/other + name: volumes-other`, + expectedVolumes: `- name: volumes-required + persistentVolumeClaim: + claimName: required +- name: volumes-other + persistentVolumeClaim: + claimName: also`, + expectedMissing: []string{}, + }, { + tcName: "none", + additionalVolumes: []v1beta1.AdditionalVolume{{ + Containers: []string{}, + ClaimName: "required", + Name: "required", + }}, + expectedContainers: `- name: database + resources: {} +- name: other + resources: {}`, + expectedInitContainers: `- name: startup + resources: {} +- name: config + resources: {}`, + expectedVolumes: `- name: volumes-required + persistentVolumeClaim: + claimName: required`, + expectedMissing: []string{}, + }, { + tcName: "multiple additional volumes", + additionalVolumes: []v1beta1.AdditionalVolume{{ + ClaimName: "required", + Name: "required", + }, { + ClaimName: "also", + Name: "other", + }}, + expectedContainers: `- name: database + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + - mountPath: /volumes/other + name: volumes-other +- name: other + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + - mountPath: /volumes/other + name: volumes-other`, + expectedInitContainers: `- name: startup + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + - mountPath: /volumes/other + name: volumes-other +- name: config + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + - mountPath: /volumes/other + name: volumes-other`, + expectedVolumes: `- name: volumes-required + persistentVolumeClaim: + claimName: required +- name: volumes-other + persistentVolumeClaim: + claimName: also`, + expectedMissing: []string{}, + }, { + tcName: "database and startup containers only", + additionalVolumes: []v1beta1.AdditionalVolume{{ + Containers: []string{"database", "startup"}, + ClaimName: "required", + Name: "required", + }}, + expectedContainers: `- name: database + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required +- name: other + resources: {}`, + expectedInitContainers: `- name: startup + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required +- name: config + resources: {}`, + expectedVolumes: `- name: volumes-required + persistentVolumeClaim: + claimName: required`, + expectedMissing: []string{}, + }, { + tcName: "container is missing", + additionalVolumes: []v1beta1.AdditionalVolume{{ + Containers: []string{"database", "startup", "missing", "container"}, + ClaimName: "required", + Name: "required", + }}, + expectedContainers: `- name: database + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required +- name: other + resources: {}`, + expectedInitContainers: `- name: startup + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required +- name: config + resources: {}`, + expectedVolumes: `- name: volumes-required + persistentVolumeClaim: + claimName: required`, + expectedMissing: []string{"missing", "container"}, + }, { + tcName: "readonly", + additionalVolumes: []v1beta1.AdditionalVolume{{ + Containers: []string{"database"}, + ClaimName: "required", + Name: "required", + ReadOnly: true, + }}, + expectedContainers: `- name: database + resources: {} + volumeMounts: + - mountPath: /volumes/required + name: volumes-required + readOnly: true +- name: other + resources: {}`, + expectedInitContainers: `- name: startup + resources: {} +- name: config + resources: {}`, + expectedVolumes: `- name: volumes-required + persistentVolumeClaim: + claimName: required + readOnly: true`, + expectedMissing: []string{}, + }} + + for _, tc := range testCases { + t.Run(tc.tcName, func(t *testing.T) { + + copyPodTemplate := podTemplate.DeepCopy() + + missingContainers := addAdditionalVolumesToSpecifiedContainers( + copyPodTemplate, + tc.additionalVolumes, + ) + + assert.Assert(t, cmp.MarshalMatches( + copyPodTemplate.Spec.Containers, + tc.expectedContainers)) + assert.Assert(t, cmp.MarshalMatches( + copyPodTemplate.Spec.InitContainers, + tc.expectedInitContainers)) + assert.Assert(t, cmp.MarshalMatches( + copyPodTemplate.Spec.Volumes, + tc.expectedVolumes)) + if len(tc.expectedMissing) == 0 { + assert.Assert(t, cmp.DeepEqual( + missingContainers, + tc.expectedMissing)) + } else { + for _, mc := range tc.expectedMissing { + assert.Assert(t, cmp.Contains( + missingContainers, + mc)) + } + } + }) + } +} diff --git a/internal/controller/postgrescluster/volumes.go b/internal/controller/postgrescluster/volumes.go index e40710d4ff..a26fa05e78 100644 --- a/internal/controller/postgrescluster/volumes.go +++ b/internal/controller/postgrescluster/volumes.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -31,10 +31,11 @@ import ( // +kubebuilder:rbac:groups="",resources="persistentvolumeclaims",verbs={list} // observePersistentVolumeClaims reads all PVCs for cluster from the Kubernetes -// API and sets the PersistentVolumeResizing condition as appropriate. +// API and sets the PersistentVolumeResizing and/or the PersistentVolumeResizeError +// conditions as appropriate. func (r *Reconciler) observePersistentVolumeClaims( ctx context.Context, cluster *v1beta1.PostgresCluster, -) ([]corev1.PersistentVolumeClaim, error) { +) ([]*corev1.PersistentVolumeClaim, error) { volumes := &corev1.PersistentVolumeClaimList{} selector, err := naming.AsSelector(naming.Cluster(cluster.Name)) @@ -53,6 +54,12 @@ func (r *Reconciler) observePersistentVolumeClaims( ObservedGeneration: cluster.Generation, } + // create a condition for surfacing any PVC resize error conditions + resizingError := metav1.Condition{ + Type: v1beta1.PersistentVolumeResizeError, + ObservedGeneration: cluster.Generation, + } + minNotZero := func(a, b metav1.Time) metav1.Time { if b.IsZero() || (a.Before(&b) && !a.IsZero()) { return a @@ -119,7 +126,31 @@ func (r *Reconciler) observePersistentVolumeClaims( resizing.LastTransitionTime = minNotZero( resizing.LastTransitionTime, condition.LastTransitionTime) } + case + // The "ControllerResizeError" and "NodeResizeError" conditions were added in + // Kubernetes v1.31 for indicating node and controller failures when resizing + // a volume: + // - https://github.com/kubernetes/enhancements/pull/4692 + // - https://github.com/kubernetes/kubernetes/pull/126108 + corev1.PersistentVolumeClaimControllerResizeError, + corev1.PersistentVolumeClaimNodeResizeError: + + // Add pertinent details from the resize error condition in the PVC to the resize + // error condition in the PostgresCluster status. In the event that there is both + // a controller resize error and a node resize error, only the details from one + // will be displayed at a time in the PostgresCluster condition. + if condition.Status == corev1.ConditionTrue { + resizingError.Status = metav1.ConditionStatus(condition.Status) + resizingError.Reason = condition.Reason + resizingError.Message = condition.Message + resizingError.LastTransitionTime = condition.LastTransitionTime + // corev1.PersistentVolumeClaimCondition.Reason is optional + // while metav1.Condition.Reason is required. + if resizingError.Reason == "" { + resizingError.Reason = string(condition.Type) + } + } case // The "ModifyingVolume" and "ModifyVolumeError" conditions occur // when the attribute class of a PVC is changing. These attributes @@ -140,7 +171,13 @@ func (r *Reconciler) observePersistentVolumeClaims( meta.RemoveStatusCondition(&cluster.Status.Conditions, resizing.Type) } - return volumes.Items, err + if resizingError.Status != "" { + meta.SetStatusCondition(&cluster.Status.Conditions, resizingError) + } else { + meta.RemoveStatusCondition(&cluster.Status.Conditions, resizingError.Type) + } + + return initialize.Pointers(volumes.Items...), err } // configureExistingPVCs configures the defined pgData, pg_wal and pgBackRest @@ -151,8 +188,8 @@ func (r *Reconciler) observePersistentVolumeClaims( // bootstrapping. func (r *Reconciler) configureExistingPVCs( ctx context.Context, cluster *v1beta1.PostgresCluster, - volumes []corev1.PersistentVolumeClaim, -) ([]corev1.PersistentVolumeClaim, error) { + volumes []*corev1.PersistentVolumeClaim, +) ([]*corev1.PersistentVolumeClaim, error) { var err error @@ -197,9 +234,9 @@ func (r *Reconciler) configureExistingPVCs( func (r *Reconciler) configureExistingPGVolumes( ctx context.Context, cluster *v1beta1.PostgresCluster, - volumes []corev1.PersistentVolumeClaim, + volumes []*corev1.PersistentVolumeClaim, instanceName string, -) ([]corev1.PersistentVolumeClaim, error) { +) ([]*corev1.PersistentVolumeClaim, error) { // if the volume is already in the list, move on for i := range volumes { @@ -217,10 +254,10 @@ func (r *Reconciler) configureExistingPGVolumes( Name: volName, Namespace: cluster.Namespace, }, - Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec, + Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec.AsPersistentVolumeClaimSpec(), } - volume.ObjectMeta.Labels = map[string]string{ + volume.Labels = map[string]string{ naming.LabelCluster: cluster.Name, naming.LabelInstanceSet: cluster.Spec.InstanceSets[0].Name, naming.LabelInstance: instanceName, @@ -235,7 +272,7 @@ func (r *Reconciler) configureExistingPGVolumes( if err := errors.WithStack(r.apply(ctx, volume)); err != nil { return volumes, err } - volumes = append(volumes, *volume) + volumes = append(volumes, volume) } } return volumes, nil @@ -250,9 +287,9 @@ func (r *Reconciler) configureExistingPGVolumes( func (r *Reconciler) configureExistingPGWALVolume( ctx context.Context, cluster *v1beta1.PostgresCluster, - volumes []corev1.PersistentVolumeClaim, + volumes []*corev1.PersistentVolumeClaim, instanceName string, -) ([]corev1.PersistentVolumeClaim, error) { +) ([]*corev1.PersistentVolumeClaim, error) { // if the volume is already in the list, move on for i := range volumes { @@ -270,10 +307,10 @@ func (r *Reconciler) configureExistingPGWALVolume( Name: volName, Namespace: cluster.Namespace, }, - Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec, + Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec.AsPersistentVolumeClaimSpec(), } - volume.ObjectMeta.Labels = map[string]string{ + volume.Labels = map[string]string{ naming.LabelCluster: cluster.Name, naming.LabelInstanceSet: cluster.Spec.InstanceSets[0].Name, naming.LabelInstance: instanceName, @@ -288,7 +325,7 @@ func (r *Reconciler) configureExistingPGWALVolume( if err := errors.WithStack(r.apply(ctx, volume)); err != nil { return volumes, err } - volumes = append(volumes, *volume) + volumes = append(volumes, volume) } return volumes, nil } @@ -302,8 +339,8 @@ func (r *Reconciler) configureExistingPGWALVolume( func (r *Reconciler) configureExistingRepoVolumes( ctx context.Context, cluster *v1beta1.PostgresCluster, - volumes []corev1.PersistentVolumeClaim, -) ([]corev1.PersistentVolumeClaim, error) { + volumes []*corev1.PersistentVolumeClaim, +) ([]*corev1.PersistentVolumeClaim, error) { // if the volume is already in the list, move on for i := range volumes { @@ -325,7 +362,7 @@ func (r *Reconciler) configureExistingRepoVolumes( cluster.Spec.Backups.PGBackRest.Repos[0].Name), }, Spec: cluster.Spec.Backups.PGBackRest.Repos[0].Volume. - VolumeClaimSpec, + VolumeClaimSpec.AsPersistentVolumeClaimSpec(), } //volume.ObjectMeta = naming.PGBackRestRepoVolume(cluster, cluster.Spec.Backups.PGBackRest.Repos[0].Name) @@ -337,7 +374,7 @@ func (r *Reconciler) configureExistingRepoVolumes( if err := errors.WithStack(r.apply(ctx, volume)); err != nil { return volumes, err } - volumes = append(volumes, *volume) + volumes = append(volumes, volume) } } return volumes, nil @@ -354,8 +391,8 @@ func (r *Reconciler) reconcileDirMoveJobs(ctx context.Context, if cluster.Spec.DataSource != nil && cluster.Spec.DataSource.Volumes != nil { - moveJobs := &batchv1.JobList{} - if err := r.Client.List(ctx, moveJobs, &client.ListOptions{ + var list batchv1.JobList + if err := r.Client.List(ctx, &list, &client.ListOptions{ Namespace: cluster.Namespace, LabelSelector: naming.DirectoryMoveJobLabels(cluster.Name).AsSelector(), }); err != nil { @@ -364,6 +401,7 @@ func (r *Reconciler) reconcileDirMoveJobs(ctx context.Context, var err error var pgDataReturn, pgWALReturn, repoReturn bool + var moveJobs = initialize.Pointers(list.Items...) if cluster.Spec.DataSource.Volumes.PGDataVolume != nil && cluster.Spec.DataSource.Volumes.PGDataVolume. @@ -405,19 +443,19 @@ func (r *Reconciler) reconcileDirMoveJobs(ctx context.Context, // main control loop should continue or return early to allow time for the job // to complete. func (r *Reconciler) reconcileMovePGDataDir(ctx context.Context, - cluster *v1beta1.PostgresCluster, moveJobs *batchv1.JobList) (bool, error) { + cluster *v1beta1.PostgresCluster, moveJobs []*batchv1.Job) (bool, error) { moveDirJob := &batchv1.Job{} moveDirJob.ObjectMeta = naming.MovePGDataDirJob(cluster) // check for an existing Job - for i := range moveJobs.Items { - if moveJobs.Items[i].Name == moveDirJob.Name { - if jobCompleted(&moveJobs.Items[i]) { + for i := range moveJobs { + if moveJobs[i].Name == moveDirJob.Name { + if jobCompleted(moveJobs[i]) { // if the Job is completed, return as this only needs to run once return false, nil } - if !jobFailed(&moveJobs.Items[i]) { + if !jobFailed(moveJobs[i]) { // if the Job otherwise exists and has not failed, return and // give the Job time to finish return true, nil @@ -427,14 +465,14 @@ func (r *Reconciler) reconcileMovePGDataDir(ctx context.Context, // at this point, the Job either wasn't found or it has failed, so the it // should be created - moveDirJob.ObjectMeta.Annotations = naming.Merge(cluster.Spec.Metadata. + moveDirJob.Annotations = naming.Merge(cluster.Spec.Metadata. GetAnnotationsOrNil()) labels := naming.Merge(cluster.Spec.Metadata.GetLabelsOrNil(), naming.DirectoryMoveJobLabels(cluster.Name), map[string]string{ naming.LabelMovePGDataDir: "", }) - moveDirJob.ObjectMeta.Labels = labels + moveDirJob.Labels = labels // `patroni.dynamic.json` holds the previous state of the DCS. Since we are // migrating the volumes, we want to clear out any obsolete configuration info. @@ -469,7 +507,9 @@ func (r *Reconciler) reconcileMovePGDataDir(ctx context.Context, jobSpec := &batchv1.JobSpec{ Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: labels}, + ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{ + naming.DefaultContainerAnnotation: naming.ContainerJobMovePGDataDir, + }}, Spec: corev1.PodSpec{ // Set the image pull secrets, if any exist. // This is set here rather than using the service account due to the lack @@ -528,19 +568,19 @@ func (r *Reconciler) reconcileMovePGDataDir(ctx context.Context, // main control loop should continue or return early to allow time for the job // to complete. func (r *Reconciler) reconcileMoveWALDir(ctx context.Context, - cluster *v1beta1.PostgresCluster, moveJobs *batchv1.JobList) (bool, error) { + cluster *v1beta1.PostgresCluster, moveJobs []*batchv1.Job) (bool, error) { moveDirJob := &batchv1.Job{} moveDirJob.ObjectMeta = naming.MovePGWALDirJob(cluster) // check for an existing Job - for i := range moveJobs.Items { - if moveJobs.Items[i].Name == moveDirJob.Name { - if jobCompleted(&moveJobs.Items[i]) { + for i := range moveJobs { + if moveJobs[i].Name == moveDirJob.Name { + if jobCompleted(moveJobs[i]) { // if the Job is completed, return as this only needs to run once return false, nil } - if !jobFailed(&moveJobs.Items[i]) { + if !jobFailed(moveJobs[i]) { // if the Job otherwise exists and has not failed, return and // give the Job time to finish return true, nil @@ -548,14 +588,14 @@ func (r *Reconciler) reconcileMoveWALDir(ctx context.Context, } } - moveDirJob.ObjectMeta.Annotations = naming.Merge(cluster.Spec.Metadata. + moveDirJob.Annotations = naming.Merge(cluster.Spec.Metadata. GetAnnotationsOrNil()) labels := naming.Merge(cluster.Spec.Metadata.GetLabelsOrNil(), naming.DirectoryMoveJobLabels(cluster.Name), map[string]string{ naming.LabelMovePGWalDir: "", }) - moveDirJob.ObjectMeta.Labels = labels + moveDirJob.Labels = labels script := fmt.Sprintf(`echo "Preparing cluster %s volumes for PGO v5.x" echo "pg_wal_pvc=%s" @@ -570,7 +610,7 @@ func (r *Reconciler) reconcileMoveWALDir(ctx context.Context, cluster.Spec.DataSource.Volumes.PGWALVolume.PVCName, cluster.Spec.DataSource.Volumes.PGWALVolume.Directory, cluster.Spec.DataSource.Volumes.PGWALVolume.Directory, - cluster.ObjectMeta.Name) + cluster.Name) container := corev1.Container{ Command: []string{"bash", "-ceu", script}, @@ -586,7 +626,9 @@ func (r *Reconciler) reconcileMoveWALDir(ctx context.Context, jobSpec := &batchv1.JobSpec{ Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: labels}, + ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{ + naming.DefaultContainerAnnotation: naming.ContainerJobMovePGWALDir, + }}, Spec: corev1.PodSpec{ // Set the image pull secrets, if any exist. // This is set here rather than using the service account due to the lack @@ -645,19 +687,19 @@ func (r *Reconciler) reconcileMoveWALDir(ctx context.Context, // indicating whether the main control loop should continue or return early // to allow time for the job to complete. func (r *Reconciler) reconcileMoveRepoDir(ctx context.Context, - cluster *v1beta1.PostgresCluster, moveJobs *batchv1.JobList) (bool, error) { + cluster *v1beta1.PostgresCluster, moveJobs []*batchv1.Job) (bool, error) { moveDirJob := &batchv1.Job{} moveDirJob.ObjectMeta = naming.MovePGBackRestRepoDirJob(cluster) // check for an existing Job - for i := range moveJobs.Items { - if moveJobs.Items[i].Name == moveDirJob.Name { - if jobCompleted(&moveJobs.Items[i]) { + for i := range moveJobs { + if moveJobs[i].Name == moveDirJob.Name { + if jobCompleted(moveJobs[i]) { // if the Job is completed, return as this only needs to run once return false, nil } - if !jobFailed(&moveJobs.Items[i]) { + if !jobFailed(moveJobs[i]) { // if the Job otherwise exists and has not failed, return and // give the Job time to finish return true, nil @@ -665,14 +707,14 @@ func (r *Reconciler) reconcileMoveRepoDir(ctx context.Context, } } - moveDirJob.ObjectMeta.Annotations = naming.Merge( + moveDirJob.Annotations = naming.Merge( cluster.Spec.Metadata.GetAnnotationsOrNil()) labels := naming.Merge(cluster.Spec.Metadata.GetLabelsOrNil(), naming.DirectoryMoveJobLabels(cluster.Name), map[string]string{ naming.LabelMovePGBackRestRepoDir: "", }) - moveDirJob.ObjectMeta.Labels = labels + moveDirJob.Labels = labels script := fmt.Sprintf(`echo "Preparing cluster %s pgBackRest repo volume for PGO v5.x" echo "repo_pvc=%s" @@ -708,7 +750,9 @@ func (r *Reconciler) reconcileMoveRepoDir(ctx context.Context, jobSpec := &batchv1.JobSpec{ Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: labels}, + ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: map[string]string{ + naming.DefaultContainerAnnotation: naming.ContainerJobMovePGBackRestRepoDir, + }}, Spec: corev1.PodSpec{ // Set the image pull secrets, if any exist. // This is set here rather than using the service account due to the lack @@ -853,23 +897,12 @@ func getRepoPVCNames( return repoPVCs } -// getPGPVCName returns the name of a PVC that has the provided labels, if found. -func getPGPVCName(labelMap map[string]string, - clusterVolumes []corev1.PersistentVolumeClaim, -) (string, error) { - - selector, err := naming.AsSelector(metav1.LabelSelector{ - MatchLabels: labelMap, - }) - if err != nil { - return "", errors.WithStack(err) - } - - for _, pvc := range clusterVolumes { +// getPVCName returns the name of a PVC that matches the selector, if any. +func getPVCName(volumes []*corev1.PersistentVolumeClaim, selector labels.Selector) string { + for _, pvc := range volumes { if selector.Matches(labels.Set(pvc.GetLabels())) { - return pvc.GetName(), nil + return pvc.GetName() } } - - return "", nil + return "" } diff --git a/internal/controller/postgrescluster/volumes_test.go b/internal/controller/postgrescluster/volumes_test.go index 96eef5f916..85087d079b 100644 --- a/internal/controller/postgrescluster/volumes_test.go +++ b/internal/controller/postgrescluster/volumes_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -16,6 +16,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/apimachinery/pkg/util/wait" "sigs.k8s.io/controller-runtime/pkg/client" @@ -295,7 +296,7 @@ func TestGetPVCNameMethods(t *testing.T) { naming.LabelInstance: "testinstance1-abcd", naming.LabelRole: naming.RolePostgresWAL, } - clusterVolumes := []corev1.PersistentVolumeClaim{*pgDataPVC, *walPVC} + clusterVolumes := []*corev1.PersistentVolumeClaim{pgDataPVC, walPVC} repoPVC1 := pvc.DeepCopy() repoPVC1.Name = "testrepovol1" @@ -319,26 +320,24 @@ func TestGetPVCNameMethods(t *testing.T) { t.Run("get pgdata PVC", func(t *testing.T) { - pvcNames, err := getPGPVCName(map[string]string{ + pvcNames := getPVCName(clusterVolumes, labels.SelectorFromSet(map[string]string{ naming.LabelCluster: cluster.Name, naming.LabelInstanceSet: "testinstance1", naming.LabelInstance: "testinstance1-abcd", naming.LabelRole: naming.RolePostgresData, - }, clusterVolumes) - assert.NilError(t, err) + })) assert.Assert(t, pvcNames == "testpgdatavol") }) t.Run("get wal PVC", func(t *testing.T) { - pvcNames, err := getPGPVCName(map[string]string{ + pvcNames := getPVCName(clusterVolumes, labels.SelectorFromSet(map[string]string{ naming.LabelCluster: cluster.Name, naming.LabelInstanceSet: "testinstance1", naming.LabelInstance: "testinstance1-abcd", naming.LabelRole: naming.RolePostgresWAL, - }, clusterVolumes) - assert.NilError(t, err) + })) assert.Assert(t, pvcNames == "testwalvol") }) @@ -392,7 +391,7 @@ func TestReconcileConfigureExistingPVCs(t *testing.T) { }, InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ Name: "instance1", - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{ corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ @@ -408,7 +407,7 @@ func TestReconcileConfigureExistingPVCs(t *testing.T) { Repos: []v1beta1.PGBackRestRepo{{ Name: "repo1", Volume: &v1beta1.RepoPVC{ - VolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + VolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{ corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ @@ -440,7 +439,7 @@ func TestReconcileConfigureExistingPVCs(t *testing.T) { "somelabel": "labelvalue-pgdata", }, }, - Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec, + Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec.AsPersistentVolumeClaimSpec(), } assert.NilError(t, tClient.Create(ctx, volume)) @@ -505,7 +504,7 @@ func TestReconcileConfigureExistingPVCs(t *testing.T) { "somelabel": "labelvalue-pgwal", }, }, - Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec, + Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec.AsPersistentVolumeClaimSpec(), } assert.NilError(t, tClient.Create(ctx, pgWALVolume)) @@ -571,7 +570,7 @@ func TestReconcileConfigureExistingPVCs(t *testing.T) { "somelabel": "labelvalue-repo", }, }, - Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec, + Spec: cluster.Spec.InstanceSets[0].DataVolumeClaimSpec.AsPersistentVolumeClaimSpec(), } assert.NilError(t, tClient.Create(ctx, volume)) @@ -675,7 +674,7 @@ func TestReconcileMoveDirectories(t *testing.T) { }, }, PriorityClassName: initialize.String("some-priority-class"), - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + DataVolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{ corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ @@ -699,7 +698,7 @@ func TestReconcileMoveDirectories(t *testing.T) { Repos: []v1beta1.PGBackRestRepo{{ Name: "repo1", Volume: &v1beta1.RepoPVC{ - VolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + VolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{ corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ diff --git a/internal/controller/postgrescluster/watches.go b/internal/controller/postgrescluster/watches.go index 0b5ba5fa87..56568bd26f 100644 --- a/internal/controller/postgrescluster/watches.go +++ b/internal/controller/postgrescluster/watches.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -20,7 +20,7 @@ import ( // watchPods returns a handler.EventHandler for Pods. func (*Reconciler) watchPods() handler.Funcs { return handler.Funcs{ - UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.RateLimitingInterface) { + UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { labels := e.ObjectNew.GetLabels() cluster := labels[naming.LabelCluster] @@ -50,7 +50,7 @@ func (*Reconciler) watchPods() handler.Funcs { } // Queue an event to start applying changes if the PostgreSQL instance - // now has the "master" role. + // now has the "primary" role. if len(cluster) != 0 && !patroni.PodIsPrimary(e.ObjectOld) && patroni.PodIsPrimary(e.ObjectNew) { diff --git a/internal/controller/postgrescluster/watches_test.go b/internal/controller/postgrescluster/watches_test.go index fdea498862..a2d5f82268 100644 --- a/internal/controller/postgrescluster/watches_test.go +++ b/internal/controller/postgrescluster/watches_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -19,7 +19,7 @@ import ( func TestWatchPodsUpdate(t *testing.T) { ctx := context.Background() - queue := &controllertest.Queue{Interface: workqueue.New()} + queue := &controllertest.Queue{TypedInterface: workqueue.NewTyped[reconcile.Request]()} reconciler := &Reconciler{} update := reconciler.watchPods().UpdateFunc diff --git a/internal/controller/runtime/client.go b/internal/controller/runtime/client.go index 4cc05c9835..e2dbf4db06 100644 --- a/internal/controller/runtime/client.go +++ b/internal/controller/runtime/client.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/runtime/conversion.go b/internal/controller/runtime/conversion.go new file mode 100644 index 0000000000..ae4495e865 --- /dev/null +++ b/internal/controller/runtime/conversion.go @@ -0,0 +1,73 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package runtime + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ( + GR = schema.GroupResource + GV = schema.GroupVersion + GVK = schema.GroupVersionKind + GVR = schema.GroupVersionResource +) + +// These functions call the [runtime.DefaultUnstructuredConverter] with some additional type safety. +// An [unstructured.Unstructured] should always be paired with a [client.Object], and +// an [unstructured.UnstructuredList] should always be paired with a [client.ObjectList]. + +// FromUnstructuredList returns a copy of list by marshaling through JSON. +func FromUnstructuredList[ + // *T implements [client.ObjectList] + T any, PT interface { + client.ObjectList + *T + }, +](list *unstructured.UnstructuredList) (*T, error) { + result := new(T) + return result, runtime. + DefaultUnstructuredConverter. + FromUnstructured(list.UnstructuredContent(), result) +} + +// FromUnstructuredObject returns a copy of object by marshaling through JSON. +func FromUnstructuredObject[ + // *T implements [client.Object] + T any, PT interface { + client.Object + *T + }, +](object *unstructured.Unstructured) (*T, error) { + result := new(T) + return result, runtime. + DefaultUnstructuredConverter. + FromUnstructured(object.UnstructuredContent(), result) +} + +// ToUnstructuredList returns a copy of list by marshaling through JSON. +func ToUnstructuredList(list client.ObjectList) (*unstructured.UnstructuredList, error) { + content, err := runtime. + DefaultUnstructuredConverter. + ToUnstructured(list) + + result := new(unstructured.UnstructuredList) + result.SetUnstructuredContent(content) + return result, err +} + +// ToUnstructuredObject returns a copy of object by marshaling through JSON. +func ToUnstructuredObject(object client.Object) (*unstructured.Unstructured, error) { + content, err := runtime. + DefaultUnstructuredConverter. + ToUnstructured(object) + + result := new(unstructured.Unstructured) + result.SetUnstructuredContent(content) + return result, err +} diff --git a/internal/controller/runtime/conversion_test.go b/internal/controller/runtime/conversion_test.go new file mode 100644 index 0000000000..e728c4978c --- /dev/null +++ b/internal/controller/runtime/conversion_test.go @@ -0,0 +1,46 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package runtime_test + +import ( + "testing" + + "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + + "github.com/crunchydata/postgres-operator/internal/controller/runtime" +) + +func TestConvertUnstructured(t *testing.T) { + var cm corev1.ConfigMap + cm.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) + cm.Namespace = "one" + cm.Name = "two" + cm.Data = map[string]string{"w": "x", "y": "z"} + + t.Run("List", func(t *testing.T) { + original := new(corev1.ConfigMapList) + original.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMapList")) + original.Items = []corev1.ConfigMap{*cm.DeepCopy()} + + list, err := runtime.ToUnstructuredList(original) + assert.NilError(t, err) + + converted, err := runtime.FromUnstructuredList[corev1.ConfigMapList](list) + assert.NilError(t, err) + assert.DeepEqual(t, original, converted) + }) + + t.Run("Object", func(t *testing.T) { + original := cm.DeepCopy() + + object, err := runtime.ToUnstructuredObject(original) + assert.NilError(t, err) + + converted, err := runtime.FromUnstructuredObject[corev1.ConfigMap](object) + assert.NilError(t, err) + assert.DeepEqual(t, original, converted) + }) +} diff --git a/internal/controller/runtime/pod_client.go b/internal/controller/runtime/pod_client.go index e842601aa7..c47673757c 100644 --- a/internal/controller/runtime/pod_client.go +++ b/internal/controller/runtime/pod_client.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -24,8 +24,8 @@ type podExecutor func( ) error func newPodClient(config *rest.Config) (rest.Interface, error) { - codecs := serializer.NewCodecFactory(scheme.Scheme) - gvk, _ := apiutil.GVKForObject(&corev1.Pod{}, scheme.Scheme) + codecs := serializer.NewCodecFactory(Scheme) + gvk, _ := apiutil.GVKForObject(&corev1.Pod{}, Scheme) httpClient, err := rest.HTTPClientFor(config) if err != nil { return nil, err diff --git a/internal/controller/runtime/reconcile.go b/internal/controller/runtime/reconcile.go index a2196d1626..5bccc4568c 100644 --- a/internal/controller/runtime/reconcile.go +++ b/internal/controller/runtime/reconcile.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,9 +7,21 @@ package runtime import ( "time" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) +// Requests converts objects to a slice of [reconcile.Request]. +func Requests[T client.Object](objects ...T) []reconcile.Request { + result := make([]reconcile.Request, len(objects)) + for i := range objects { + result[i] = reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(objects[i]), + } + } + return result +} + // ErrorWithBackoff returns a Result and error that indicate a non-nil err // should be logged and measured and its [reconcile.Request] should be retried // later. When err is nil, nothing is logged and the Request is not retried. diff --git a/internal/controller/runtime/reconcile_test.go b/internal/controller/runtime/reconcile_test.go index 925b3cf47d..b50802cbaa 100644 --- a/internal/controller/runtime/reconcile_test.go +++ b/internal/controller/runtime/reconcile_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -10,9 +10,36 @@ import ( "time" "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" ) +func TestRequests(t *testing.T) { + none := Requests[client.Object]() + assert.Assert(t, none != nil, "does not return nil slice") + assert.DeepEqual(t, none, []reconcile.Request{}) + + assert.Assert(t, cmp.Panics(func() { + Requests[client.Object](nil) + }), "expected nil pointer dereference") + + // Empty request when no metadata. + assert.DeepEqual(t, Requests(new(corev1.Secret)), []reconcile.Request{{}}) + + secret := new(corev1.Secret) + secret.Namespace = "asdf" + + expected := reconcile.Request{} + expected.Namespace = "asdf" + assert.DeepEqual(t, Requests(secret), []reconcile.Request{expected}) + + secret.Name = "123" + expected.Name = "123" + assert.DeepEqual(t, Requests(secret), []reconcile.Request{expected}) +} + func TestErrorWithBackoff(t *testing.T) { result, err := ErrorWithBackoff(nil) assert.Assert(t, result.IsZero()) diff --git a/internal/controller/runtime/runtime.go b/internal/controller/runtime/runtime.go index 34bfeabf61..6618470418 100644 --- a/internal/controller/runtime/runtime.go +++ b/internal/controller/runtime/runtime.go @@ -1,12 +1,11 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package runtime import ( - "context" - + volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" @@ -14,12 +13,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/config" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/manager" - "sigs.k8s.io/controller-runtime/pkg/manager/signals" "github.com/crunchydata/postgres-operator/internal/logging" + v1 "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" - - volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1" ) type ( @@ -38,6 +35,9 @@ func init() { if err := v1beta1.AddToScheme(Scheme); err != nil { panic(err) } + if err := v1.AddToScheme(Scheme); err != nil { + panic(err) + } if err := volumesnapshotv1.AddToScheme(Scheme); err != nil { panic(err) } @@ -71,6 +71,3 @@ func NewManager(config *rest.Config, options manager.Options) (manager.Manager, // SetLogger assigns the default Logger used by [sigs.k8s.io/controller-runtime]. func SetLogger(logger logging.Logger) { log.SetLogger(logger) } - -// SignalHandler returns a Context that is canceled on SIGINT or SIGTERM. -func SignalHandler() context.Context { return signals.SetupSignalHandler() } diff --git a/internal/controller/runtime/ticker.go b/internal/controller/runtime/ticker.go index 830179eafc..0f4669d0ac 100644 --- a/internal/controller/runtime/ticker.go +++ b/internal/controller/runtime/ticker.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -9,15 +9,17 @@ import ( "time" "k8s.io/client-go/util/workqueue" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" "sigs.k8s.io/controller-runtime/pkg/source" ) type ticker struct { time.Duration event.GenericEvent - Handler handler.EventHandler + Handler handler.TypedEventHandler[client.Object, reconcile.Request] Immediate bool } @@ -38,7 +40,7 @@ func (t ticker) String() string { return "every " + t.Duration.String() } // Start is called by controller-runtime Controller and returns quickly. // It cleans up when ctx is cancelled. func (t ticker) Start( - ctx context.Context, q workqueue.RateLimitingInterface, + ctx context.Context, q workqueue.TypedRateLimitingInterface[reconcile.Request], ) error { ticker := time.NewTicker(t.Duration) diff --git a/internal/controller/runtime/ticker_test.go b/internal/controller/runtime/ticker_test.go index 49cecd79d7..49a2ab32da 100644 --- a/internal/controller/runtime/ticker_test.go +++ b/internal/controller/runtime/ticker_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -14,6 +14,7 @@ import ( "k8s.io/client-go/util/workqueue" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) func TestTickerString(t *testing.T) { @@ -28,8 +29,8 @@ func TestTicker(t *testing.T) { var called []event.GenericEvent expected := event.GenericEvent{Object: new(corev1.ConfigMap)} - tq := workqueue.NewRateLimitingQueue(workqueue.DefaultItemBasedRateLimiter()) - th := handler.Funcs{GenericFunc: func(ctx context.Context, e event.GenericEvent, q workqueue.RateLimitingInterface) { + tq := workqueue.NewTypedRateLimitingQueue(workqueue.DefaultTypedItemBasedRateLimiter[reconcile.Request]()) + th := handler.Funcs{GenericFunc: func(ctx context.Context, e event.GenericEvent, q workqueue.TypedRateLimitingInterface[reconcile.Request]) { called = append(called, e) assert.Equal(t, q, tq, "should be called with the queue passed in Start") diff --git a/internal/controller/standalone_pgadmin/apply.go b/internal/controller/standalone_pgadmin/apply.go index 0eaa613df8..0cc3191967 100644 --- a/internal/controller/standalone_pgadmin/apply.go +++ b/internal/controller/standalone_pgadmin/apply.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -22,7 +22,7 @@ func (r *PGAdminReconciler) patch( patch client.Patch, options ...client.PatchOption, ) error { options = append([]client.PatchOption{r.Owner}, options...) - return r.Client.Patch(ctx, object, patch, options...) + return r.Patch(ctx, object, patch, options...) } // apply sends an apply patch to object's endpoint in the Kubernetes API and diff --git a/internal/controller/standalone_pgadmin/config.go b/internal/controller/standalone_pgadmin/config.go index ddd080985b..3af09144f2 100644 --- a/internal/controller/standalone_pgadmin/config.go +++ b/internal/controller/standalone_pgadmin/config.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/standalone_pgadmin/configmap.go b/internal/controller/standalone_pgadmin/configmap.go index d1ec39bf13..d2378802c3 100644 --- a/internal/controller/standalone_pgadmin/configmap.go +++ b/internal/controller/standalone_pgadmin/configmap.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -9,13 +9,16 @@ import ( "context" "encoding/json" "fmt" + "maps" + "slices" "sort" "strconv" - - corev1 "k8s.io/api/core/v1" + "strings" "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" @@ -27,9 +30,15 @@ import ( // reconcilePGAdminConfigMap writes the ConfigMap for pgAdmin. func (r *PGAdminReconciler) reconcilePGAdminConfigMap( ctx context.Context, pgadmin *v1beta1.PGAdmin, - clusters map[string]*v1beta1.PostgresClusterList, + clusters map[string][]*v1beta1.PostgresCluster, ) (*corev1.ConfigMap, error) { - configmap, err := configmap(pgadmin, clusters) + configmap, err := configmap(ctx, pgadmin, clusters) + if err != nil { + return configmap, err + } + + err = collector.EnablePgAdminLogging(ctx, pgadmin.Spec.Instrumentation, configmap) + if err == nil { err = errors.WithStack(r.setControllerReference(pgadmin, configmap)) } @@ -41,8 +50,8 @@ func (r *PGAdminReconciler) reconcilePGAdminConfigMap( } // configmap returns a v1.ConfigMap for pgAdmin. -func configmap(pgadmin *v1beta1.PGAdmin, - clusters map[string]*v1beta1.PostgresClusterList, +func configmap(ctx context.Context, pgadmin *v1beta1.PGAdmin, + clusters map[string][]*v1beta1.PostgresCluster, ) (*corev1.ConfigMap, error) { configmap := &corev1.ConfigMap{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} configmap.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("ConfigMap")) @@ -54,7 +63,38 @@ func configmap(pgadmin *v1beta1.PGAdmin, // TODO(tjmoore4): Populate configuration details. initialize.Map(&configmap.Data) - configSettings, err := generateConfig(pgadmin) + var ( + logRetention bool + maxBackupRetentionNumber = 1 + // One day in minutes for pgadmin rotation + pgAdminRetentionPeriod = 24 * 60 + // Daily rotation for gunicorn rotation + gunicornRetentionPeriod = "D" + ) + // If OTel logs feature gate is enabled, we want to change the pgAdmin/gunicorn logging + if collector.OpenTelemetryLogsEnabled(ctx, pgadmin) { + logRetention = true + + // If the user has set a retention period, we will use those values for log rotation, + // which is otherwise managed by python. + if pgadmin.Spec.Instrumentation.Logs != nil && + pgadmin.Spec.Instrumentation.Logs.RetentionPeriod != nil { + + retentionNumber, period := collector.ParseDurationForLogrotate(pgadmin.Spec.Instrumentation.Logs.RetentionPeriod.AsDuration()) + // `LOG_ROTATION_MAX_LOG_FILES`` in pgadmin refers to the already rotated logs. + // `backupCount` for gunicorn is similar. + // Our retention unit is for total number of log files, so subtract 1 to account + // for the currently-used log file. + maxBackupRetentionNumber = retentionNumber - 1 + if period == "hourly" { + // If the period is hourly, set the pgadmin + // and gunicorn retention periods to hourly. + pgAdminRetentionPeriod = 60 + gunicornRetentionPeriod = "H" + } + } + } + configSettings, err := generateConfig(pgadmin, logRetention, maxBackupRetentionNumber, pgAdminRetentionPeriod) if err == nil { configmap.Data[settingsConfigMapKey] = configSettings } @@ -64,7 +104,8 @@ func configmap(pgadmin *v1beta1.PGAdmin, configmap.Data[settingsClusterMapKey] = clusterSettings } - gunicornSettings, err := generateGunicornConfig(pgadmin) + gunicornSettings, err := generateGunicornConfig(pgadmin, + logRetention, maxBackupRetentionNumber, gunicornRetentionPeriod) if err == nil { configmap.Data[gunicornConfigKey] = gunicornSettings } @@ -72,8 +113,10 @@ func configmap(pgadmin *v1beta1.PGAdmin, return configmap, err } -// generateConfig generates the config settings for the pgAdmin -func generateConfig(pgadmin *v1beta1.PGAdmin) (string, error) { +// generateConfigs generates the config settings for the pgAdmin and gunicorn +func generateConfig(pgadmin *v1beta1.PGAdmin, + logRetention bool, maxBackupRetentionNumber, pgAdminRetentionPeriod int) ( + string, error) { settings := map[string]any{ // Bind to all IPv4 addresses by default. "0.0.0.0" here represents INADDR_ANY. // - https://flask.palletsprojects.com/en/2.2.x/api/#flask.Flask.run @@ -82,9 +125,7 @@ func generateConfig(pgadmin *v1beta1.PGAdmin) (string, error) { } // Copy any specified settings over the defaults. - for k, v := range pgadmin.Spec.Config.Settings { - settings[k] = v - } + maps.Copy(settings, pgadmin.Spec.Config.Settings) // Write mandatory settings over any specified ones. // SERVER_MODE must always be enabled when running on a webserver. @@ -93,6 +134,22 @@ func generateConfig(pgadmin *v1beta1.PGAdmin) (string, error) { settings["UPGRADE_CHECK_ENABLED"] = false settings["UPGRADE_CHECK_URL"] = "" settings["UPGRADE_CHECK_KEY"] = "" + settings["DATA_DIR"] = dataMountPath + settings["LOG_FILE"] = LogFileAbsolutePath + + if logRetention { + settings["LOG_ROTATION_AGE"] = pgAdminRetentionPeriod + settings["LOG_ROTATION_MAX_LOG_FILES"] = maxBackupRetentionNumber + settings["JSON_LOGGER"] = true + settings["CONSOLE_LOG_LEVEL"] = "WARNING" + settings["FILE_LOG_LEVEL"] = "INFO" + settings["FILE_LOG_FORMAT_JSON"] = map[string]string{ + "time": "created", + "name": "name", + "level": "levelname", + "message": "message", + } + } // To avoid spurious reconciles, the following value must not change when // the spec does not change. [json.Encoder] and [json.Marshal] do this by @@ -126,7 +183,7 @@ func generateConfig(pgadmin *v1beta1.PGAdmin) (string, error) { // } // } func generateClusterConfig( - clusters map[string]*v1beta1.PostgresClusterList, + clusters map[string][]*v1beta1.PostgresCluster, ) (string, error) { // To avoid spurious reconciles, the following value must not change when // the spec does not change. [json.Encoder] and [json.Marshal] do this by @@ -149,11 +206,10 @@ func generateClusterConfig( clusterServers := map[int]any{} for _, serverGroupName := range keys { - sort.Slice(clusters[serverGroupName].Items, - func(i, j int) bool { - return clusters[serverGroupName].Items[i].Name < clusters[serverGroupName].Items[j].Name - }) - for _, cluster := range clusters[serverGroupName].Items { + slices.SortFunc(clusters[serverGroupName], func(a, b *v1beta1.PostgresCluster) int { + return strings.Compare(a.Name, b.Name) + }) + for _, cluster := range clusters[serverGroupName] { object := map[string]any{ "Name": cluster.Name, "Group": serverGroupName, @@ -177,7 +233,9 @@ func generateClusterConfig( // generateGunicornConfig generates the config settings for the gunicorn server // - https://docs.gunicorn.org/en/latest/settings.html -func generateGunicornConfig(pgadmin *v1beta1.PGAdmin) (string, error) { +func generateGunicornConfig(pgadmin *v1beta1.PGAdmin, + logRetention bool, maxBackupRetentionNumber int, gunicornRetentionPeriod string, +) (string, error) { settings := map[string]any{ // Bind to all IPv4 addresses and set 25 threads by default. // - https://docs.gunicorn.org/en/latest/settings.html#bind @@ -187,13 +245,74 @@ func generateGunicornConfig(pgadmin *v1beta1.PGAdmin) (string, error) { } // Copy any specified settings over the defaults. - for k, v := range pgadmin.Spec.Config.Gunicorn { - settings[k] = v - } + maps.Copy(settings, pgadmin.Spec.Config.Gunicorn) // Write mandatory settings over any specified ones. // - https://docs.gunicorn.org/en/latest/settings.html#workers settings["workers"] = 1 + // Gunicorn logging dict settings + logSettings := map[string]any{} + + // If OTel logs feature gate is enabled, we want to change the gunicorn logging + if logRetention { + + // Gunicorn uses the Python logging package, which sets the following attributes: + // https://docs.python.org/3/library/logging.html#logrecord-attributes. + // JsonFormatter is used to format the log: https://pypi.org/project/jsonformatter/ + // We override the gunicorn defaults (using `logconfig_dict`) to set our own file handler. + // - https://docs.gunicorn.org/en/stable/settings.html#logconfig-dict + // - https://github.com/benoitc/gunicorn/blob/23.0.0/gunicorn/glogging.py#L47 + logSettings = map[string]any{ + + "loggers": map[string]any{ + "gunicorn.access": map[string]any{ + "handlers": []string{"file"}, + "level": "INFO", + "propagate": true, + "qualname": "gunicorn.access", + }, + "gunicorn.error": map[string]any{ + "handlers": []string{"file"}, + "level": "INFO", + "propagate": true, + "qualname": "gunicorn.error", + }, + }, + "handlers": map[string]any{ + "file": map[string]any{ + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": GunicornLogFileAbsolutePath, + "backupCount": maxBackupRetentionNumber, + "interval": 1, + "when": gunicornRetentionPeriod, + "formatter": "json", + }, + "console": map[string]any{ + "class": "logging.StreamHandler", + "formatter": "generic", + "stream": "ext://sys.stdout", + }, + }, + "formatters": map[string]any{ + "generic": map[string]any{ + "class": "logging.Formatter", + "datefmt": "[%Y-%m-%d %H:%M:%S %z]", + "format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s", + }, + "json": map[string]any{ + "class": "jsonformatter.JsonFormatter", + "separators": []string{",", ":"}, + "format": map[string]string{ + "time": "created", + "name": "name", + "level": "levelname", + "message": "message", + }, + }, + }, + } + } + settings["logconfig_dict"] = logSettings // To avoid spurious reconciles, the following value must not change when // the spec does not change. [json.Encoder] and [json.Marshal] do this by diff --git a/internal/controller/standalone_pgadmin/configmap_test.go b/internal/controller/standalone_pgadmin/configmap_test.go index 5a844e520c..3a9bab7b28 100644 --- a/internal/controller/standalone_pgadmin/configmap_test.go +++ b/internal/controller/standalone_pgadmin/configmap_test.go @@ -1,10 +1,11 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package standalone_pgadmin import ( + "context" "testing" "gotest.tools/v3/assert" @@ -19,11 +20,13 @@ func TestGenerateConfig(t *testing.T) { t.Run("Default", func(t *testing.T) { pgadmin := new(v1beta1.PGAdmin) - result, err := generateConfig(pgadmin) + result, err := generateConfig(pgadmin, false, 0, 0) assert.NilError(t, err) assert.Equal(t, result, `{ + "DATA_DIR": "/var/lib/pgadmin", "DEFAULT_SERVER": "0.0.0.0", + "LOG_FILE": "/var/lib/pgadmin/logs/pgadmin.log", "SERVER_MODE": true, "UPGRADE_CHECK_ENABLED": false, "UPGRADE_CHECK_KEY": "", @@ -37,11 +40,13 @@ func TestGenerateConfig(t *testing.T) { "SERVER_MODE": false, "UPGRADE_CHECK_ENABLED": true, } - result, err := generateConfig(pgadmin) + result, err := generateConfig(pgadmin, false, 0, 0) assert.NilError(t, err) assert.Equal(t, result, `{ + "DATA_DIR": "/var/lib/pgadmin", "DEFAULT_SERVER": "0.0.0.0", + "LOG_FILE": "/var/lib/pgadmin/logs/pgadmin.log", "SERVER_MODE": true, "UPGRADE_CHECK_ENABLED": false, "UPGRADE_CHECK_KEY": "", @@ -55,7 +60,7 @@ func TestGenerateConfig(t *testing.T) { "ALLOWED_HOSTS": []any{"225.0.0.0/8", "226.0.0.0/7", "228.0.0.0/6"}, "DEFAULT_SERVER": "::", } - result, err := generateConfig(pgadmin) + result, err := generateConfig(pgadmin, false, 0, 0) assert.NilError(t, err) assert.Equal(t, result, `{ @@ -64,7 +69,41 @@ func TestGenerateConfig(t *testing.T) { "226.0.0.0/7", "228.0.0.0/6" ], + "DATA_DIR": "/var/lib/pgadmin", "DEFAULT_SERVER": "::", + "LOG_FILE": "/var/lib/pgadmin/logs/pgadmin.log", + "SERVER_MODE": true, + "UPGRADE_CHECK_ENABLED": false, + "UPGRADE_CHECK_KEY": "", + "UPGRADE_CHECK_URL": "" +}`+"\n") + }) + + t.Run("OTel enabled", func(t *testing.T) { + pgadmin := new(v1beta1.PGAdmin) + require.UnmarshalInto(t, &pgadmin.Spec, `{ + instrumentation: { + logs: { retentionPeriod: 5h }, + }, + }`) + result, err := generateConfig(pgadmin, true, 4, 60) + + assert.NilError(t, err) + assert.Equal(t, result, `{ + "CONSOLE_LOG_LEVEL": "WARNING", + "DATA_DIR": "/var/lib/pgadmin", + "DEFAULT_SERVER": "0.0.0.0", + "FILE_LOG_FORMAT_JSON": { + "level": "levelname", + "message": "message", + "name": "name", + "time": "created" + }, + "FILE_LOG_LEVEL": "INFO", + "JSON_LOGGER": true, + "LOG_FILE": "/var/lib/pgadmin/logs/pgadmin.log", + "LOG_ROTATION_AGE": 60, + "LOG_ROTATION_MAX_LOG_FILES": 4, "SERVER_MODE": true, "UPGRADE_CHECK_ENABLED": false, "UPGRADE_CHECK_KEY": "", @@ -76,15 +115,13 @@ func TestGenerateConfig(t *testing.T) { func TestGenerateClusterConfig(t *testing.T) { require.ParallelCapacity(t, 0) - cluster := testCluster() + cluster := v1beta1.NewPostgresCluster() cluster.Namespace = "postgres-operator" - clusterList := &v1beta1.PostgresClusterList{ - Items: []v1beta1.PostgresCluster{*cluster, *cluster}, - } - clusters := map[string]*v1beta1.PostgresClusterList{ - "shared": clusterList, - "test": clusterList, - "hello": clusterList, + cluster.Name = "hippo" + clusters := map[string][]*v1beta1.PostgresCluster{ + "shared": {cluster, cluster}, + "test": {cluster, cluster}, + "hello": {cluster, cluster}, } expectedString := `{ @@ -163,11 +200,12 @@ func TestGeneratePGAdminConfigMap(t *testing.T) { pgadmin := new(v1beta1.PGAdmin) pgadmin.Namespace = "some-ns" pgadmin.Name = "pg1" - clusters := map[string]*v1beta1.PostgresClusterList{} + clusters := map[string][]*v1beta1.PostgresCluster{} + ctx := context.Background() t.Run("Data,ObjectMeta,TypeMeta", func(t *testing.T) { pgadmin := pgadmin.DeepCopy() - configmap, err := configmap(pgadmin, clusters) + configmap, err := configmap(ctx, pgadmin, clusters) assert.NilError(t, err) assert.Assert(t, cmp.MarshalMatches(configmap.TypeMeta, ` @@ -193,16 +231,16 @@ namespace: some-ns Labels: map[string]string{"c": "v3", "d": "v4"}, } - configmap, err := configmap(pgadmin, clusters) + configmap, err := configmap(ctx, pgadmin, clusters) assert.NilError(t, err) // Annotations present in the metadata. - assert.DeepEqual(t, configmap.ObjectMeta.Annotations, map[string]string{ + assert.DeepEqual(t, configmap.Annotations, map[string]string{ "a": "v1", "b": "v2", }) // Labels present in the metadata. - assert.DeepEqual(t, configmap.ObjectMeta.Labels, map[string]string{ + assert.DeepEqual(t, configmap.Labels, map[string]string{ "c": "v3", "d": "v4", "postgres-operator.crunchydata.com/pgadmin": "pg1", "postgres-operator.crunchydata.com/role": "pgadmin", @@ -220,11 +258,12 @@ func TestGenerateGunicornConfig(t *testing.T) { expectedString := `{ "bind": "0.0.0.0:5050", + "logconfig_dict": {}, "threads": 25, "workers": 1 } ` - actualString, err := generateGunicornConfig(pgAdmin) + actualString, err := generateGunicornConfig(pgAdmin, false, 0, "H") assert.NilError(t, err) assert.Equal(t, actualString, expectedString) }) @@ -242,11 +281,12 @@ func TestGenerateGunicornConfig(t *testing.T) { "bind": "0.0.0.0:5050", "certfile": "/path/to/certfile", "keyfile": "/path/to/keyfile", + "logconfig_dict": {}, "threads": 25, "workers": 1 } ` - actualString, err := generateGunicornConfig(pgAdmin) + actualString, err := generateGunicornConfig(pgAdmin, false, 0, "H") assert.NilError(t, err) assert.Equal(t, actualString, expectedString) }) @@ -262,11 +302,12 @@ func TestGenerateGunicornConfig(t *testing.T) { expectedString := `{ "bind": "127.0.0.1:5051", + "logconfig_dict": {}, "threads": 30, "workers": 1 } ` - actualString, err := generateGunicornConfig(pgAdmin) + actualString, err := generateGunicornConfig(pgAdmin, false, 0, "H") assert.NilError(t, err) assert.Equal(t, actualString, expectedString) }) @@ -281,11 +322,89 @@ func TestGenerateGunicornConfig(t *testing.T) { expectedString := `{ "bind": "0.0.0.0:5050", + "logconfig_dict": {}, "threads": 25, "workers": 1 } ` - actualString, err := generateGunicornConfig(pgAdmin) + actualString, err := generateGunicornConfig(pgAdmin, false, 0, "H") + assert.NilError(t, err) + assert.Equal(t, actualString, expectedString) + }) + + t.Run("OTel enabled", func(t *testing.T) { + pgAdmin := &v1beta1.PGAdmin{} + pgAdmin.Name = "test" + pgAdmin.Namespace = "postgres-operator" + require.UnmarshalInto(t, &pgAdmin.Spec, `{ + instrumentation: { + logs: { retentionPeriod: 5h }, + }, + }`) + actualString, err := generateGunicornConfig(pgAdmin, true, 4, "H") + + expectedString := `{ + "bind": "0.0.0.0:5050", + "logconfig_dict": { + "formatters": { + "generic": { + "class": "logging.Formatter", + "datefmt": "[%Y-%m-%d %H:%M:%S %z]", + "format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s" + }, + "json": { + "class": "jsonformatter.JsonFormatter", + "format": { + "level": "levelname", + "message": "message", + "name": "name", + "time": "created" + }, + "separators": [ + ",", + ":" + ] + } + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "generic", + "stream": "ext://sys.stdout" + }, + "file": { + "backupCount": 4, + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": "/var/lib/pgadmin/logs/gunicorn.log", + "formatter": "json", + "interval": 1, + "when": "H" + } + }, + "loggers": { + "gunicorn.access": { + "handlers": [ + "file" + ], + "level": "INFO", + "propagate": true, + "qualname": "gunicorn.access" + }, + "gunicorn.error": { + "handlers": [ + "file" + ], + "level": "INFO", + "propagate": true, + "qualname": "gunicorn.error" + } + } + }, + "threads": 25, + "workers": 1 +} +` + assert.NilError(t, err) assert.Equal(t, actualString, expectedString) }) diff --git a/internal/controller/standalone_pgadmin/controller.go b/internal/controller/standalone_pgadmin/controller.go index 81d5fc2d40..a8b95b0053 100644 --- a/internal/controller/standalone_pgadmin/controller.go +++ b/internal/controller/standalone_pgadmin/controller.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -16,9 +16,11 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" - controllerruntime "github.com/crunchydata/postgres-operator/internal/controller/runtime" + "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/logging" + "github.com/crunchydata/postgres-operator/internal/tracing" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -30,8 +32,7 @@ type PGAdminReconciler struct { ctx context.Context, namespace, pod, container string, stdin io.Reader, stdout, stderr io.Writer, command ...string, ) error - Recorder record.EventRecorder - IsOpenShift bool + Recorder record.EventRecorder } //+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="pgadmins",verbs={list,watch} @@ -47,7 +48,7 @@ type PGAdminReconciler struct { func (r *PGAdminReconciler) SetupWithManager(mgr ctrl.Manager) error { if r.PodExec == nil { var err error - r.PodExec, err = controllerruntime.NewPodExecutor(mgr.GetConfig()) + r.PodExec, err = runtime.NewPodExecutor(mgr.GetConfig()) if err != nil { return err } @@ -62,11 +63,15 @@ func (r *PGAdminReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&corev1.Service{}). Watches( v1beta1.NewPostgresCluster(), - r.watchPostgresClusters(), + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, cluster client.Object) []ctrl.Request { + return runtime.Requests(r.findPGAdminsForPostgresCluster(ctx, cluster)...) + }), ). Watches( &corev1.Secret{}, - r.watchForRelatedSecret(), + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, secret client.Object) []ctrl.Request { + return runtime.Requests(r.findPGAdminsForSecret(ctx, client.ObjectKeyFromObject(secret))...) + }), ). Complete(r) } @@ -79,14 +84,16 @@ func (r *PGAdminReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *PGAdminReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { var err error + ctx, span := tracing.Start(ctx, "reconcile-pgadmin") log := logging.FromContext(ctx) + defer span.End() pgAdmin := &v1beta1.PGAdmin{} if err := r.Get(ctx, req.NamespacedName, pgAdmin); err != nil { // NotFound cannot be fixed by requeuing so ignore it. During background // deletion, we receive delete events from pgadmin's dependents after // pgadmin is deleted. - return ctrl.Result{}, client.IgnoreNotFound(err) + return ctrl.Result{}, tracing.Escape(span, client.IgnoreNotFound(err)) } // Write any changes to the pgadmin status on the way out. @@ -111,7 +118,7 @@ func (r *PGAdminReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct var ( configmap *corev1.ConfigMap dataVolume *corev1.PersistentVolumeClaim - clusters map[string]*v1beta1.PostgresClusterList + clusters map[string][]*v1beta1.PostgresCluster _ *corev1.Service ) @@ -141,7 +148,7 @@ func (r *PGAdminReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct log.V(1).Info("Reconciled pgAdmin") } - return ctrl.Result{}, err + return ctrl.Result{}, tracing.Escape(span, err) } // The owner reference created by controllerutil.SetControllerReference blocks @@ -159,7 +166,7 @@ func (r *PGAdminReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct func (r *PGAdminReconciler) setControllerReference( owner *v1beta1.PGAdmin, controlled client.Object, ) error { - return controllerutil.SetControllerReference(owner, controlled, r.Client.Scheme()) + return controllerutil.SetControllerReference(owner, controlled, r.Scheme()) } // deleteControlled safely deletes object when it is controlled by pgAdmin. @@ -171,7 +178,7 @@ func (r *PGAdminReconciler) deleteControlled( version := object.GetResourceVersion() exactly := client.Preconditions{UID: &uid, ResourceVersion: &version} - return r.Client.Delete(ctx, object, exactly) + return r.Delete(ctx, object, exactly) } return nil diff --git a/internal/controller/standalone_pgadmin/controller_test.go b/internal/controller/standalone_pgadmin/controller_test.go index b0fe17cbe6..4dd984d8ef 100644 --- a/internal/controller/standalone_pgadmin/controller_test.go +++ b/internal/controller/standalone_pgadmin/controller_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -29,6 +29,12 @@ func TestDeleteControlled(t *testing.T) { pgadmin := new(v1beta1.PGAdmin) pgadmin.Namespace = ns.Name pgadmin.Name = strings.ToLower(t.Name()) + require.UnmarshalInto(t, &pgadmin.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, + }, + }`) assert.NilError(t, cc.Create(ctx, pgadmin)) t.Run("NoOwnership", func(t *testing.T) { diff --git a/internal/controller/standalone_pgadmin/helpers_test.go b/internal/controller/standalone_pgadmin/helpers_test.go index 9096edb5a1..abcb1b5f38 100644 --- a/internal/controller/standalone_pgadmin/helpers_test.go +++ b/internal/controller/standalone_pgadmin/helpers_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/standalone_pgadmin/helpers_unit_test.go b/internal/controller/standalone_pgadmin/helpers_unit_test.go deleted file mode 100644 index 63887385fc..0000000000 --- a/internal/controller/standalone_pgadmin/helpers_unit_test.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. -// -// SPDX-License-Identifier: Apache-2.0 - -package standalone_pgadmin - -import ( - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - - "github.com/crunchydata/postgres-operator/internal/initialize" - "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" -) - -// TODO(benjaminjb): This file is duplicated test help functions -// that could probably be put into a separate test_helper package - -var ( - //TODO(tjmoore4): With the new RELATED_IMAGES defaulting behavior, tests could be refactored - // to reference those environment variables instead of hard coded image values - CrunchyPostgresHAImage = "registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-13.6-1" - CrunchyPGBackRestImage = "registry.developers.crunchydata.com/crunchydata/crunchy-pgbackrest:ubi8-2.38-0" - CrunchyPGBouncerImage = "registry.developers.crunchydata.com/crunchydata/crunchy-pgbouncer:ubi8-1.16-2" -) - -func testCluster() *v1beta1.PostgresCluster { - // Defines a base cluster spec that can be used by tests to generate a - // cluster with an expected number of instances - cluster := v1beta1.PostgresCluster{ - ObjectMeta: metav1.ObjectMeta{ - Name: "hippo", - }, - Spec: v1beta1.PostgresClusterSpec{ - PostgresVersion: 13, - Image: CrunchyPostgresHAImage, - ImagePullSecrets: []corev1.LocalObjectReference{{ - Name: "myImagePullSecret"}, - }, - InstanceSets: []v1beta1.PostgresInstanceSetSpec{{ - Name: "instance1", - Replicas: initialize.Int32(1), - DataVolumeClaimSpec: testVolumeClaimSpec(), - }}, - Backups: v1beta1.Backups{ - PGBackRest: v1beta1.PGBackRestArchive{ - Image: CrunchyPGBackRestImage, - Repos: []v1beta1.PGBackRestRepo{{ - Name: "repo1", - Volume: &v1beta1.RepoPVC{ - VolumeClaimSpec: testVolumeClaimSpec(), - }, - }}, - }, - }, - Proxy: &v1beta1.PostgresProxySpec{ - PGBouncer: &v1beta1.PGBouncerPodSpec{ - Image: CrunchyPGBouncerImage, - }, - }, - }, - } - return cluster.DeepCopy() -} - -func testVolumeClaimSpec() corev1.PersistentVolumeClaimSpec { - // Defines a volume claim spec that can be used to create instances - return corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: map[corev1.ResourceName]resource.Quantity{ - corev1.ResourceStorage: resource.MustParse("1Gi"), - }, - }, - } -} diff --git a/internal/controller/standalone_pgadmin/pod.go b/internal/controller/standalone_pgadmin/pod.go index bbb39b9322..71f785c15e 100644 --- a/internal/controller/standalone_pgadmin/pod.go +++ b/internal/controller/standalone_pgadmin/pod.go @@ -1,10 +1,11 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package standalone_pgadmin import ( + "context" "fmt" "strings" @@ -14,7 +15,9 @@ import ( "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/kubernetes" "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/shell" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -25,9 +28,20 @@ const ( configDatabaseURIPath = "~postgres-operator/config-database-uri" ldapFilePath = "~postgres-operator/ldap-bind-password" gunicornConfigFilePath = "~postgres-operator/" + gunicornConfigKey + oauthConfigDir = "~postgres-operator/oauth-config" + oauthAbsolutePath = configMountPath + "/" + oauthConfigDir - // Nothing should be mounted to this location except the script our initContainer writes + // scriptMountPath is where to mount a temporary directory that is only + // writable during Pod initialization. + // + // NOTE: No ConfigMap nor Secret should ever be mounted here because they + // could be used to inject code through "config_system.py". scriptMountPath = "/etc/pgadmin" + + dataMountPath = "/var/lib/pgadmin" + LogDirectoryAbsolutePath = dataMountPath + "/logs" + GunicornLogFileAbsolutePath = LogDirectoryAbsolutePath + "/gunicorn.log" + LogFileAbsolutePath = LogDirectoryAbsolutePath + "/pgadmin.log" ) // pod populates a PodSpec with the container and volumes needed to run pgAdmin. @@ -37,19 +51,10 @@ func pod( outPod *corev1.PodSpec, pgAdminVolume *corev1.PersistentVolumeClaim, ) { - const ( - // config and data volume names - configVolumeName = "pgadmin-config" - dataVolumeName = "pgadmin-data" - logVolumeName = "pgadmin-log" - scriptVolumeName = "pgadmin-config-system" - tempVolumeName = "tmp" - ) - // create the projected volume of config maps for use in // 1. dynamic server discovery // 2. adding the config variables during pgAdmin startup - configVolume := corev1.Volume{Name: configVolumeName} + configVolume := corev1.Volume{Name: "pgadmin-config"} configVolume.VolumeSource = corev1.VolumeSource{ Projected: &corev1.ProjectedVolumeSource{ Sources: podConfigFiles(inConfigMap, *inPGAdmin), @@ -57,7 +62,7 @@ func pod( } // create the data volume for the persistent database - dataVolume := corev1.Volume{Name: dataVolumeName} + dataVolume := corev1.Volume{Name: "pgadmin-data"} dataVolume.VolumeSource = corev1.VolumeSource{ PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ ClaimName: pgAdminVolume.Name, @@ -65,17 +70,9 @@ func pod( }, } - // create the temp volume for logs - logVolume := corev1.Volume{Name: logVolumeName} - logVolume.VolumeSource = corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{ - Medium: corev1.StorageMediumMemory, - }, - } - // Volume used to write a custom config_system.py file in the initContainer // which then loads the configs found in the `configVolume` - scriptVolume := corev1.Volume{Name: scriptVolumeName} + scriptVolume := corev1.Volume{Name: "pgadmin-config-system"} scriptVolume.VolumeSource = corev1.VolumeSource{ EmptyDir: &corev1.EmptyDirVolumeSource{ Medium: corev1.StorageMediumMemory, @@ -88,15 +85,6 @@ func pod( }, } - // create a temp volume for restart pid/other/debugging use - // TODO: discuss tmp vol vs. persistent vol - tmpVolume := corev1.Volume{Name: tempVolumeName} - tmpVolume.VolumeSource = corev1.VolumeSource{ - EmptyDir: &corev1.EmptyDirVolumeSource{ - Medium: corev1.StorageMediumMemory, - }, - } - // pgadmin container container := corev1.Container{ Name: naming.ContainerPGAdmin, @@ -131,37 +119,29 @@ func pod( }, VolumeMounts: []corev1.VolumeMount{ { - Name: configVolumeName, + Name: configVolume.Name, MountPath: configMountPath, ReadOnly: true, }, { - Name: dataVolumeName, - MountPath: "/var/lib/pgadmin", - }, - { - Name: logVolumeName, - MountPath: "/var/log/pgadmin", + Name: dataVolume.Name, + MountPath: dataMountPath, }, { - Name: scriptVolumeName, + Name: scriptVolume.Name, MountPath: scriptMountPath, ReadOnly: true, }, - { - Name: tempVolumeName, - MountPath: "/tmp", - }, }, } - // Creating a readiness probe that will check that the pgAdmin `/login` + // Creating a readiness probe that will check that the pgAdmin `/misc/ping` // endpoint is reachable at the specified port readinessProbe := &corev1.Probe{ ProbeHandler: corev1.ProbeHandler{ HTTPGet: &corev1.HTTPGetAction{ Port: intstr.FromInt32(pgAdminPort), - Path: "/login", + Path: "/misc/ping", Scheme: corev1.URISchemeHTTP, }, }, @@ -170,7 +150,7 @@ func pod( // Check the configmap to see if we think TLS is enabled // If so, update the readiness check scheme to HTTPS if strings.Contains(gunicornData, "certfile") && strings.Contains(gunicornData, "keyfile") { - readinessProbe.ProbeHandler.HTTPGet.Scheme = corev1.URISchemeHTTPS + readinessProbe.HTTPGet.Scheme = corev1.URISchemeHTTPS } container.ReadinessProbe = readinessProbe @@ -184,10 +164,14 @@ func pod( VolumeMounts: []corev1.VolumeMount{ // Volume to write a custom `config_system.py` file to. { - Name: scriptVolumeName, + Name: scriptVolume.Name, MountPath: scriptMountPath, ReadOnly: false, }, + { + Name: dataVolume.Name, + MountPath: dataMountPath, + }, }, } @@ -195,9 +179,7 @@ func pod( outPod.Volumes = []corev1.Volume{ configVolume, dataVolume, - logVolume, scriptVolume, - tmpVolume, } outPod.Containers = []corev1.Container{container} outPod.InitContainers = []corev1.Container{startup} @@ -232,18 +214,22 @@ func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin) []core }, }...) + for i, oauth := range pgadmin.Spec.Config.OAuthConfigurations { + // Safely encode the OAUTH2_NAME in the file name. Prepend the index so + // the files can be loaded in the order they are defined in the spec. + mountPath := fmt.Sprintf( + "%s/%02d-%s.json", oauthConfigDir, i, shell.CleanFileName(oauth.Name), + ) + config = append(config, corev1.VolumeProjection{ + Secret: initialize.Pointer(oauth.Secret.AsProjection(mountPath)), + }) + } + if pgadmin.Spec.Config.ConfigDatabaseURI != nil { config = append(config, corev1.VolumeProjection{ - Secret: &corev1.SecretProjection{ - LocalObjectReference: pgadmin.Spec.Config.ConfigDatabaseURI.LocalObjectReference, - Optional: pgadmin.Spec.Config.ConfigDatabaseURI.Optional, - Items: []corev1.KeyToPath{ - { - Key: pgadmin.Spec.Config.ConfigDatabaseURI.Key, - Path: configDatabaseURIPath, - }, - }, - }, + Secret: initialize.Pointer( + pgadmin.Spec.Config.ConfigDatabaseURI.AsProjection(configDatabaseURIPath), + ), }) } @@ -257,16 +243,9 @@ func podConfigFiles(configmap *corev1.ConfigMap, pgadmin v1beta1.PGAdmin) []core // - https://www.pgadmin.org/docs/pgadmin4/development/enabling_ldap_authentication.html if pgadmin.Spec.Config.LDAPBindPassword != nil { config = append(config, corev1.VolumeProjection{ - Secret: &corev1.SecretProjection{ - LocalObjectReference: pgadmin.Spec.Config.LDAPBindPassword.LocalObjectReference, - Optional: pgadmin.Spec.Config.LDAPBindPassword.Optional, - Items: []corev1.KeyToPath{ - { - Key: pgadmin.Spec.Config.LDAPBindPassword.Key, - Path: ldapFilePath, - }, - }, - }, + Secret: initialize.Pointer( + pgadmin.Spec.Config.LDAPBindPassword.AsProjection(ldapFilePath), + ), }) } @@ -296,7 +275,8 @@ func startupScript(pgadmin *v1beta1.PGAdmin) []string { // startCommands (v8 image includes Gunicorn) var startCommandV7 = "pgadmin4 &" - var startCommandV8 = "gunicorn -c /etc/pgadmin/gunicorn_config.py --chdir $PGADMIN_DIR pgAdmin4:app &" + var startCommandV8 = "gunicorn -c /etc/pgadmin/gunicorn_config.py" + + " --chdir $PGADMIN_DIR pgAdmin4:app &" // This script sets up, starts pgadmin, and runs the appropriate `loadServerCommand` to register the discovered servers. // pgAdmin is hosted by Gunicorn and uses a config file. @@ -344,10 +324,17 @@ loadServerCommand // descriptor and uses the timeout of the builtin `read` to wait. That same // descriptor gets closed and reopened to use the builtin `[ -nt` to check mtimes. // - https://unix.stackexchange.com/a/407383 + // + // Gunicorn needs a SIGTERM rather than SIGHUP to reload its logging config. + // This also causes pgAdmin to restart when its configuration changes. + // - https://github.com/benoitc/gunicorn/issues/3353 + // + // Right now the config file is on the same configMap as the cluster file + // so if the mtime changes for any of those files, it will change for all. var reloadScript = ` exec {fd}<> <(:||:) while read -r -t 5 -u "${fd}" ||:; do - if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand + if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?}); then exec {fd}>&- && exec {fd}<> <(:||:) stat --format='Loaded shared servers dated %y' "${cluster_file}" @@ -394,6 +381,8 @@ func startupCommand() []string { // configDatabaseURIPath is the path for mounting the database URI connection string configDatabaseURIPathAbsolutePath = configMountPath + "/" + configDatabaseURIPath + // The values set in configSystem will not be overridden through + // spec.config.settings. configSystem = ` import glob, json, re, os DEFAULT_BINARY_PATHS = {'pg': sorted([''] + glob.glob('/usr/pgsql-*/bin')).pop()} @@ -401,25 +390,46 @@ with open('` + configMountPath + `/` + configFilePath + `') as _f: _conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f) if type(_data) is dict: globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)}) +if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list: + OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf] +for _f in reversed(glob.glob('` + oauthAbsolutePath + `/[0-9][0-9]-*.json')): + if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list: + OAUTH2_CONFIG = [] + try: + with open(_f) as _f: + _data, _name = json.load(_f), os.path.basename(_f.name)[3:-5] + _data, _next = { 'OAUTH2_NAME': _name } | _data, [] + for _conf in OAUTH2_CONFIG: + if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'): + _data = _conf | _data + else: + _next.append(_conf) + OAUTH2_CONFIG = [_data] + _next + del _next + except: + pass if os.path.isfile('` + ldapPasswordAbsolutePath + `'): with open('` + ldapPasswordAbsolutePath + `') as _f: LDAP_BIND_PASSWORD = _f.read() if os.path.isfile('` + configDatabaseURIPathAbsolutePath + `'): with open('` + configDatabaseURIPathAbsolutePath + `') as _f: CONFIG_DATABASE_URI = _f.read() +del _conf, _data, _f ` - // gunicorn reads from the `/etc/pgadmin/gunicorn_config.py` file during startup + + // Gunicorn reads from the `/etc/pgadmin/gunicorn_config.py` file during startup // after all other config files. // - https://docs.gunicorn.org/en/latest/configure.html#configuration-file // // This command writes a script in `/etc/pgadmin/gunicorn_config.py` that reads // from the `gunicorn-config.json` file and sets those variables globally. - // That way those values are available as settings when gunicorn starts. + // That way those values are available as settings when Gunicorn starts. // - // Note: All gunicorn settings are lowercase with underscores, so ignore + // Note: All Gunicorn settings are lowercase with underscores, so ignore // any keys/names that are not. gunicornConfig = ` -import json, re +import json, re, gunicorn +gunicorn.SERVER_SOFTWARE = 'Python' with open('` + configMountPath + `/` + gunicornConfigFilePath + `') as _f: _conf, _data = re.compile(r'[a-z_]+'), json.load(_f) if type(_data) is dict: @@ -430,9 +440,13 @@ with open('` + configMountPath + `/` + gunicornConfigFilePath + `') as _f: args := []string{strings.TrimLeft(configSystem, "\n"), strings.TrimLeft(gunicornConfig, "\n")} script := strings.Join([]string{ - // Use the initContainer to create this path to avoid the error noted here: + // Create the config directory so Kubernetes can mount it later. // - https://issue.k8s.io/121294 - `mkdir -p ` + configMountPath, + shell.MakeDirectories(scriptMountPath, configMountPath), + + // Create the logs directory and ensure pgAdmin can write to it as well. + shell.MakeDirectories(dataMountPath, LogDirectoryAbsolutePath), + // Write the system and server configurations. `echo "$1" > ` + scriptMountPath + `/config_system.py`, `echo "$2" > ` + scriptMountPath + `/gunicorn_config.py`, @@ -443,8 +457,8 @@ with open('` + configMountPath + `/` + gunicornConfigFilePath + `') as _f: // podSecurityContext returns a v1.PodSecurityContext for pgadmin that can write // to PersistentVolumes. -func podSecurityContext(r *PGAdminReconciler) *corev1.PodSecurityContext { - podSecurityContext := initialize.PodSecurityContext() +func podSecurityContext(ctx context.Context) *corev1.PodSecurityContext { + psc := initialize.PodSecurityContext() // TODO (dsessler7): Add ability to add supplemental groups @@ -454,9 +468,11 @@ func podSecurityContext(r *PGAdminReconciler) *corev1.PodSecurityContext { // - https://cloud.redhat.com/blog/a-guide-to-openshift-and-uids // - https://docs.k8s.io/tasks/configure-pod-container/security-context/ // - https://docs.openshift.com/container-platform/4.14/authentication/managing-security-context-constraints.html - if !r.IsOpenShift { - podSecurityContext.FSGroup = initialize.Int64(2) + if !kubernetes.Has(ctx, kubernetes.API{ + Group: "security.openshift.io", Kind: "SecurityContextConstraints", + }) { + psc.FSGroup = initialize.Int64(2) } - return podSecurityContext + return psc } diff --git a/internal/controller/standalone_pgadmin/pod_test.go b/internal/controller/standalone_pgadmin/pod_test.go index 19cee52882..b30b35bc65 100644 --- a/internal/controller/standalone_pgadmin/pod_test.go +++ b/internal/controller/standalone_pgadmin/pod_test.go @@ -1,10 +1,11 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package standalone_pgadmin import ( + "context" "testing" "gotest.tools/v3/assert" @@ -13,7 +14,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/kubernetes" "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -72,7 +75,7 @@ containers: exec {fd}<> <(:||:) while read -r -t 5 -u "${fd}" ||:; do - if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand + if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?}); then exec {fd}>&- && exec {fd}<> <(:||:) stat --format='Loaded shared servers dated %y' "${cluster_file}" @@ -105,7 +108,7 @@ containers: protocol: TCP readinessProbe: httpGet: - path: /login + path: /misc/ping port: 5050 scheme: HTTP resources: {} @@ -125,20 +128,17 @@ containers: readOnly: true - mountPath: /var/lib/pgadmin name: pgadmin-data - - mountPath: /var/log/pgadmin - name: pgadmin-log - mountPath: /etc/pgadmin name: pgadmin-config-system readOnly: true - - mountPath: /tmp - name: tmp initContainers: - command: - bash - -ceu - -- - |- - mkdir -p /etc/pgadmin/conf.d + mkdir -p '/etc/pgadmin/conf.d' && { chmod 0775 '/etc/pgadmin/conf.d' || :; } + mkdir -p '/var/lib/pgadmin/logs' && { chmod 0775 '/var/lib/pgadmin/logs' || :; } echo "$1" > /etc/pgadmin/config_system.py echo "$2" > /etc/pgadmin/gunicorn_config.py - startup @@ -149,14 +149,34 @@ initContainers: _conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f) if type(_data) is dict: globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)}) + if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list: + OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf] + for _f in reversed(glob.glob('/etc/pgadmin/conf.d/~postgres-operator/oauth-config/[0-9][0-9]-*.json')): + if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list: + OAUTH2_CONFIG = [] + try: + with open(_f) as _f: + _data, _name = json.load(_f), os.path.basename(_f.name)[3:-5] + _data, _next = { 'OAUTH2_NAME': _name } | _data, [] + for _conf in OAUTH2_CONFIG: + if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'): + _data = _conf | _data + else: + _next.append(_conf) + OAUTH2_CONFIG = [_data] + _next + del _next + except: + pass if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'): with open('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password') as _f: LDAP_BIND_PASSWORD = _f.read() if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri'): with open('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri') as _f: CONFIG_DATABASE_URI = _f.read() + del _conf, _data, _f - | - import json, re + import json, re, gunicorn + gunicorn.SERVER_SOFTWARE = 'Python' with open('/etc/pgadmin/conf.d/~postgres-operator/gunicorn-config.json') as _f: _conf, _data = re.compile(r'[a-z_]+'), json.load(_f) if type(_data) is dict: @@ -176,6 +196,8 @@ initContainers: volumeMounts: - mountPath: /etc/pgadmin name: pgadmin-config-system + - mountPath: /var/lib/pgadmin + name: pgadmin-data volumes: - name: pgadmin-config projected: @@ -191,16 +213,10 @@ volumes: - name: pgadmin-data persistentVolumeClaim: claimName: "" -- emptyDir: - medium: Memory - name: pgadmin-log - emptyDir: medium: Memory sizeLimit: 32Ki name: pgadmin-config-system -- emptyDir: - medium: Memory - name: tmp `)) // No change when called again. @@ -215,6 +231,9 @@ volumes: pgadmin.Spec.Resources.Requests = corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("100m"), } + require.UnmarshalInto(t, &pgadmin.Spec.Instrumentation, `{ + logs: { retentionPeriod: 12h }, + }`) call() @@ -257,7 +276,7 @@ containers: exec {fd}<> <(:||:) while read -r -t 5 -u "${fd}" ||:; do - if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand + if [[ "${cluster_file}" -nt "/proc/self/fd/${fd}" ]] && loadServerCommand && kill -TERM $(head -1 ${PGADMIN4_PIDFILE?}); then exec {fd}>&- && exec {fd}<> <(:||:) stat --format='Loaded shared servers dated %y' "${cluster_file}" @@ -292,7 +311,7 @@ containers: protocol: TCP readinessProbe: httpGet: - path: /login + path: /misc/ping port: 5050 scheme: HTTP resources: @@ -314,20 +333,17 @@ containers: readOnly: true - mountPath: /var/lib/pgadmin name: pgadmin-data - - mountPath: /var/log/pgadmin - name: pgadmin-log - mountPath: /etc/pgadmin name: pgadmin-config-system readOnly: true - - mountPath: /tmp - name: tmp initContainers: - command: - bash - -ceu - -- - |- - mkdir -p /etc/pgadmin/conf.d + mkdir -p '/etc/pgadmin/conf.d' && { chmod 0775 '/etc/pgadmin/conf.d' || :; } + mkdir -p '/var/lib/pgadmin/logs' && { chmod 0775 '/var/lib/pgadmin/logs' || :; } echo "$1" > /etc/pgadmin/config_system.py echo "$2" > /etc/pgadmin/gunicorn_config.py - startup @@ -338,14 +354,34 @@ initContainers: _conf, _data = re.compile(r'[A-Z_0-9]+'), json.load(_f) if type(_data) is dict: globals().update({k: v for k, v in _data.items() if _conf.fullmatch(k)}) + if 'OAUTH2_CONFIG' in globals() and type(OAUTH2_CONFIG) is list: + OAUTH2_CONFIG = [_conf for _conf in OAUTH2_CONFIG if type(_conf) is dict and 'OAUTH2_NAME' in _conf] + for _f in reversed(glob.glob('/etc/pgadmin/conf.d/~postgres-operator/oauth-config/[0-9][0-9]-*.json')): + if 'OAUTH2_CONFIG' not in globals() or type(OAUTH2_CONFIG) is not list: + OAUTH2_CONFIG = [] + try: + with open(_f) as _f: + _data, _name = json.load(_f), os.path.basename(_f.name)[3:-5] + _data, _next = { 'OAUTH2_NAME': _name } | _data, [] + for _conf in OAUTH2_CONFIG: + if _data['OAUTH2_NAME'] == _conf.get('OAUTH2_NAME'): + _data = _conf | _data + else: + _next.append(_conf) + OAUTH2_CONFIG = [_data] + _next + del _next + except: + pass if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password'): with open('/etc/pgadmin/conf.d/~postgres-operator/ldap-bind-password') as _f: LDAP_BIND_PASSWORD = _f.read() if os.path.isfile('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri'): with open('/etc/pgadmin/conf.d/~postgres-operator/config-database-uri') as _f: CONFIG_DATABASE_URI = _f.read() + del _conf, _data, _f - | - import json, re + import json, re, gunicorn + gunicorn.SERVER_SOFTWARE = 'Python' with open('/etc/pgadmin/conf.d/~postgres-operator/gunicorn-config.json') as _f: _conf, _data = re.compile(r'[a-z_]+'), json.load(_f) if type(_data) is dict: @@ -369,6 +405,8 @@ initContainers: volumeMounts: - mountPath: /etc/pgadmin name: pgadmin-config-system + - mountPath: /var/lib/pgadmin + name: pgadmin-data volumes: - name: pgadmin-config projected: @@ -384,16 +422,10 @@ volumes: - name: pgadmin-data persistentVolumeClaim: claimName: "" -- emptyDir: - medium: Memory - name: pgadmin-log - emptyDir: medium: Memory sizeLimit: 32Ki name: pgadmin-config-system -- emptyDir: - medium: Memory - name: tmp `)) }) } @@ -434,14 +466,16 @@ func TestPodConfigFiles(t *testing.T) { } func TestPodSecurityContext(t *testing.T) { - pgAdminReconciler := &PGAdminReconciler{} - - assert.Assert(t, cmp.MarshalMatches(podSecurityContext(pgAdminReconciler), ` + ctx := context.Background() + assert.Assert(t, cmp.MarshalMatches(podSecurityContext(ctx), ` fsGroup: 2 fsGroupChangePolicy: OnRootMismatch `)) - pgAdminReconciler.IsOpenShift = true - assert.Assert(t, cmp.MarshalMatches(podSecurityContext(pgAdminReconciler), + ctx = kubernetes.NewAPIContext(ctx, kubernetes.NewAPISet(kubernetes.API{ + Group: "security.openshift.io", Version: "v1", + Kind: "SecurityContextConstraints", + })) + assert.Assert(t, cmp.MarshalMatches(podSecurityContext(ctx), `fsGroupChangePolicy: OnRootMismatch`)) } diff --git a/internal/controller/standalone_pgadmin/postgrescluster.go b/internal/controller/standalone_pgadmin/related.go similarity index 57% rename from internal/controller/standalone_pgadmin/postgrescluster.go rename to internal/controller/standalone_pgadmin/related.go index 5327b8ae70..c7fcb119bc 100644 --- a/internal/controller/standalone_pgadmin/postgrescluster.go +++ b/internal/controller/standalone_pgadmin/related.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,12 +7,12 @@ package standalone_pgadmin import ( "context" - "github.com/crunchydata/postgres-operator/internal/naming" - "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crunchydata/postgres-operator/internal/initialize" + "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) //+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="pgadmins",verbs={list} @@ -27,7 +27,7 @@ func (r *PGAdminReconciler) findPGAdminsForPostgresCluster( ) // NOTE: If this becomes slow due to a large number of pgadmins in a single - // namespace, we can configure the [ctrl.Manager] field indexer and pass a + // namespace, we can configure the [manager.Manager] field indexer and pass a // [fields.Selector] here. // - https://book.kubebuilder.io/reference/watching-resources/externally-managed.html if r.List(ctx, &pgadmins, &client.ListOptions{ @@ -50,39 +50,66 @@ func (r *PGAdminReconciler) findPGAdminsForPostgresCluster( return matching } -//+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="postgresclusters",verbs={list,watch} +//+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="pgadmins",verbs={list} + +// findPGAdminsForSecret returns PGAdmins that have a user or users that have their password +// stored in the Secret +func (r *PGAdminReconciler) findPGAdminsForSecret( + ctx context.Context, secret client.ObjectKey, +) []*v1beta1.PGAdmin { + var matching []*v1beta1.PGAdmin + var pgadmins v1beta1.PGAdminList + + // NOTE: If this becomes slow due to a large number of PGAdmins in a single + // namespace, we can configure the [manager.Manager] field indexer and pass a + // [fields.Selector] here. + // - https://book.kubebuilder.io/reference/watching-resources/externally-managed.html + if err := r.List(ctx, &pgadmins, &client.ListOptions{ + Namespace: secret.Namespace, + }); err == nil { + for i := range pgadmins.Items { + for j := range pgadmins.Items[i].Spec.Users { + if pgadmins.Items[i].Spec.Users[j].PasswordRef.Name == secret.Name { + matching = append(matching, &pgadmins.Items[i]) + break + } + } + } + } + return matching +} + +//+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="postgresclusters",verbs={get,list} // getClustersForPGAdmin returns clusters managed by the given pgAdmin func (r *PGAdminReconciler) getClustersForPGAdmin( ctx context.Context, pgAdmin *v1beta1.PGAdmin, -) (map[string]*v1beta1.PostgresClusterList, error) { - matching := make(map[string]*v1beta1.PostgresClusterList) +) (map[string][]*v1beta1.PostgresCluster, error) { + matching := make(map[string][]*v1beta1.PostgresCluster) var err error var selector labels.Selector for _, serverGroup := range pgAdmin.Spec.ServerGroups { - cluster := &v1beta1.PostgresCluster{} + var cluster v1beta1.PostgresCluster if serverGroup.PostgresClusterName != "" { - err = r.Get(ctx, types.NamespacedName{ + err = r.Get(ctx, client.ObjectKey{ Name: serverGroup.PostgresClusterName, Namespace: pgAdmin.GetNamespace(), - }, cluster) + }, &cluster) if err == nil { - matching[serverGroup.Name] = &v1beta1.PostgresClusterList{ - Items: []v1beta1.PostgresCluster{*cluster}, - } + matching[serverGroup.Name] = []*v1beta1.PostgresCluster{&cluster} } continue } if selector, err = naming.AsSelector(serverGroup.PostgresClusterSelector); err == nil { - var filteredList v1beta1.PostgresClusterList - err = r.List(ctx, &filteredList, + var list v1beta1.PostgresClusterList + err = r.List(ctx, &list, client.InNamespace(pgAdmin.Namespace), client.MatchingLabelsSelector{Selector: selector}, ) if err == nil { - matching[serverGroup.Name] = &filteredList + matching[serverGroup.Name] = initialize.Pointers(list.Items...) } } } diff --git a/internal/controller/standalone_pgadmin/watches_test.go b/internal/controller/standalone_pgadmin/related_test.go similarity index 72% rename from internal/controller/standalone_pgadmin/watches_test.go rename to internal/controller/standalone_pgadmin/related_test.go index 1419eb9efa..a14e50d9e2 100644 --- a/internal/controller/standalone_pgadmin/watches_test.go +++ b/internal/controller/standalone_pgadmin/related_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -41,18 +41,19 @@ func TestFindPGAdminsForSecret(t *testing.T) { pgadmin1 := new(v1beta1.PGAdmin) pgadmin1.Namespace = ns.Name pgadmin1.Name = "first-pgadmin" - pgadmin1.Spec.Users = []v1beta1.PGAdminUser{ - { - PasswordRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "first-password-secret", - }, - Key: "password", - }, - Username: "testuser", - Role: "Administrator", + require.UnmarshalInto(t, &pgadmin1.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, }, - } + users: [ + { + username: testuser, + role: Administrator, + passwordRef: { name: first-password-secret, key: password }, + }, + ], + }`) assert.NilError(t, tClient.Create(ctx, pgadmin1)) pgadmins := reconciler.findPGAdminsForSecret(ctx, secretObjectKey) @@ -65,18 +66,19 @@ func TestFindPGAdminsForSecret(t *testing.T) { pgadmin2 := new(v1beta1.PGAdmin) pgadmin2.Namespace = ns.Name pgadmin2.Name = "second-pgadmin" - pgadmin2.Spec.Users = []v1beta1.PGAdminUser{ - { - PasswordRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "first-password-secret", - }, - Key: "password", - }, - Username: "testuser2", - Role: "Administrator", + require.UnmarshalInto(t, &pgadmin2.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, }, - } + users: [ + { + username: testuser2, + role: Administrator, + passwordRef: { name: first-password-secret, key: password }, + }, + ], + }`) assert.NilError(t, tClient.Create(ctx, pgadmin2)) pgadmins := reconciler.findPGAdminsForSecret(ctx, secretObjectKey) @@ -94,18 +96,19 @@ func TestFindPGAdminsForSecret(t *testing.T) { pgadmin3 := new(v1beta1.PGAdmin) pgadmin3.Namespace = ns.Name pgadmin3.Name = "third-pgadmin" - pgadmin3.Spec.Users = []v1beta1.PGAdminUser{ - { - PasswordRef: &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "other-password-secret", - }, - Key: "password", - }, - Username: "testuser2", - Role: "Administrator", + require.UnmarshalInto(t, &pgadmin3.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, }, - } + users: [ + { + username: testuser2, + role: Administrator, + passwordRef: { name: other-password-secret, key: password }, + }, + ], + }`) assert.NilError(t, tClient.Create(ctx, pgadmin3)) pgadmins := reconciler.findPGAdminsForSecret(ctx, secretObjectKey) diff --git a/internal/controller/standalone_pgadmin/service.go b/internal/controller/standalone_pgadmin/service.go index 2453a6a1fa..bfdc04c6ec 100644 --- a/internal/controller/standalone_pgadmin/service.go +++ b/internal/controller/standalone_pgadmin/service.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,16 +7,14 @@ package standalone_pgadmin import ( "context" + "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/controller-runtime/pkg/client" - apierrors "k8s.io/apimachinery/pkg/api/errors" - - "github.com/pkg/errors" - "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" @@ -38,7 +36,7 @@ func (r *PGAdminReconciler) reconcilePGAdminService( // need to delete any existing service(s). At the start of every reconcile // get all services that match the current pgAdmin labels. services := corev1.ServiceList{} - if err := r.Client.List(ctx, &services, + if err := r.List(ctx, &services, client.InNamespace(pgadmin.Namespace), client.MatchingLabels{ naming.LabelStandalonePGAdmin: pgadmin.Name, @@ -64,7 +62,7 @@ func (r *PGAdminReconciler) reconcilePGAdminService( if pgadmin.Spec.ServiceName != "" { // Look for an existing service with name ServiceName in the namespace existingService := &corev1.Service{} - err := r.Client.Get(ctx, types.NamespacedName{ + err := r.Get(ctx, types.NamespacedName{ Name: pgadmin.Spec.ServiceName, Namespace: pgadmin.GetNamespace(), }, existingService) diff --git a/internal/controller/standalone_pgadmin/service_test.go b/internal/controller/standalone_pgadmin/service_test.go index 24b20c8247..a15c89d7ec 100644 --- a/internal/controller/standalone_pgadmin/service_test.go +++ b/internal/controller/standalone_pgadmin/service_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/controller/standalone_pgadmin/statefulset.go b/internal/controller/standalone_pgadmin/statefulset.go index e086e333f4..b8730b7112 100644 --- a/internal/controller/standalone_pgadmin/statefulset.go +++ b/internal/controller/standalone_pgadmin/statefulset.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,14 +7,15 @@ package standalone_pgadmin import ( "context" + "github.com/pkg/errors" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/pkg/errors" - + "github.com/crunchydata/postgres-operator/internal/collector" + "github.com/crunchydata/postgres-operator/internal/controller/postgrescluster" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" @@ -25,7 +26,7 @@ func (r *PGAdminReconciler) reconcilePGAdminStatefulSet( ctx context.Context, pgadmin *v1beta1.PGAdmin, configmap *corev1.ConfigMap, dataVolume *corev1.PersistentVolumeClaim, ) error { - sts := statefulset(r, pgadmin, configmap, dataVolume) + sts := statefulset(ctx, pgadmin, configmap, dataVolume) // Previous versions of PGO used a StatefulSet Pod Management Policy that could leave the Pod // in a failed state. When we see that it has the wrong policy, we will delete the StatefulSet @@ -33,7 +34,7 @@ func (r *PGAdminReconciler) reconcilePGAdminStatefulSet( // When we delete the StatefulSet, we will leave its Pods in place. They will be claimed by // the StatefulSet that gets created in the next reconcile. existing := &appsv1.StatefulSet{} - if err := errors.WithStack(r.Client.Get(ctx, client.ObjectKeyFromObject(sts), existing)); err != nil { + if err := errors.WithStack(r.Get(ctx, client.ObjectKeyFromObject(sts), existing)); err != nil { if !apierrors.IsNotFound(err) { return err } @@ -46,7 +47,7 @@ func (r *PGAdminReconciler) reconcilePGAdminStatefulSet( exactly := client.Preconditions{UID: &uid, ResourceVersion: &version} propagate := client.PropagationPolicy(metav1.DeletePropagationOrphan) - return errors.WithStack(client.IgnoreNotFound(r.Client.Delete(ctx, existing, exactly, propagate))) + return errors.WithStack(client.IgnoreNotFound(r.Delete(ctx, existing, exactly, propagate))) } } @@ -58,7 +59,7 @@ func (r *PGAdminReconciler) reconcilePGAdminStatefulSet( // statefulset defines the StatefulSet needed to run pgAdmin. func statefulset( - r *PGAdminReconciler, + ctx context.Context, pgadmin *v1beta1.PGAdmin, configmap *corev1.ConfigMap, dataVolume *corev1.PersistentVolumeClaim, @@ -74,7 +75,12 @@ func statefulset( sts.Spec.Selector = &metav1.LabelSelector{ MatchLabels: naming.StandalonePGAdminLabels(pgadmin.Name), } - sts.Spec.Template.Annotations = pgadmin.Spec.Metadata.GetAnnotationsOrNil() + sts.Spec.Template.Annotations = naming.Merge( + pgadmin.Spec.Metadata.GetAnnotationsOrNil(), + map[string]string{ + naming.DefaultContainerAnnotation: naming.ContainerPGAdmin, + }, + ) sts.Spec.Template.Labels = naming.Merge( pgadmin.Spec.Metadata.GetLabelsOrNil(), naming.StandalonePGAdminDataLabels(pgadmin.Name), @@ -110,9 +116,27 @@ func statefulset( // set the image pull secrets, if any exist sts.Spec.Template.Spec.ImagePullSecrets = pgadmin.Spec.ImagePullSecrets - sts.Spec.Template.Spec.SecurityContext = podSecurityContext(r) + sts.Spec.Template.Spec.SecurityContext = podSecurityContext(ctx) pod(pgadmin, configmap, &sts.Spec.Template.Spec, dataVolume) + if collector.OpenTelemetryLogsEnabled(ctx, pgadmin) { + // Logs for gunicorn and pgadmin write to /var/lib/pgadmin/logs + // so the collector needs access to that path. + dataVolumeMount := corev1.VolumeMount{ + Name: "pgadmin-data", + MountPath: "/var/lib/pgadmin", + } + volumeMounts := []corev1.VolumeMount{ + dataVolumeMount, + } + + collector.AddToPod(ctx, pgadmin.Spec.Instrumentation, pgadmin.Spec.ImagePullPolicy, + configmap, &sts.Spec.Template, volumeMounts, "", []string{LogDirectoryAbsolutePath}, + false, false) + } + + postgrescluster.AddTMPEmptyDir(&sts.Spec.Template) + return sts } diff --git a/internal/controller/standalone_pgadmin/statefulset_test.go b/internal/controller/standalone_pgadmin/statefulset_test.go index 52c501b357..9d6b804476 100644 --- a/internal/controller/standalone_pgadmin/statefulset_test.go +++ b/internal/controller/standalone_pgadmin/statefulset_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -35,6 +35,12 @@ func TestReconcilePGAdminStatefulSet(t *testing.T) { pgadmin := new(v1beta1.PGAdmin) pgadmin.Name = "test-standalone-pgadmin" pgadmin.Namespace = ns.Name + require.UnmarshalInto(t, &pgadmin.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, + }, + }`) assert.NilError(t, cc.Create(ctx, pgadmin)) t.Cleanup(func() { assert.Check(t, cc.Delete(ctx, pgadmin)) }) @@ -73,6 +79,8 @@ func TestReconcilePGAdminStatefulSet(t *testing.T) { template.Spec.Volumes = nil assert.Assert(t, cmp.MarshalMatches(template.ObjectMeta, ` +annotations: + kubectl.kubernetes.io/default-container: pgadmin creationTimestamp: null labels: postgres-operator.crunchydata.com/data: pgadmin @@ -103,6 +111,12 @@ terminationGracePeriodSeconds: 30 // add pod level customizations custompgadmin.Name = "custom-pgadmin" custompgadmin.Namespace = ns.Name + require.UnmarshalInto(t, &custompgadmin.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, + }, + }`) // annotation and label custompgadmin.Spec.Metadata = &v1beta1.Metadata{ @@ -170,6 +184,7 @@ terminationGracePeriodSeconds: 30 assert.Assert(t, cmp.MarshalMatches(template.ObjectMeta, ` annotations: annotation1: annotationvalue + kubectl.kubernetes.io/default-container: pgadmin creationTimestamp: null labels: label1: labelvalue diff --git a/internal/controller/standalone_pgadmin/users.go b/internal/controller/standalone_pgadmin/users.go index 3c9a3ce05b..678a3a722b 100644 --- a/internal/controller/standalone_pgadmin/users.go +++ b/internal/controller/standalone_pgadmin/users.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -53,7 +53,7 @@ func (r *PGAdminReconciler) reconcilePGAdminUsers(ctx context.Context, pgadmin * pod := &corev1.Pod{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} pod.Name += "-0" - err := errors.WithStack(r.Client.Get(ctx, client.ObjectKeyFromObject(pod), pod)) + err := errors.WithStack(r.Get(ctx, client.ObjectKeyFromObject(pod), pod)) if err != nil { return client.IgnoreNotFound(err) } @@ -136,7 +136,7 @@ func (r *PGAdminReconciler) writePGAdminUsers(ctx context.Context, pgadmin *v1be existingUserSecret := &corev1.Secret{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} err := errors.WithStack( - r.Client.Get(ctx, client.ObjectKeyFromObject(existingUserSecret), existingUserSecret)) + r.Get(ctx, client.ObjectKeyFromObject(existingUserSecret), existingUserSecret)) if client.IgnoreNotFound(err) != nil { return err } @@ -183,10 +183,10 @@ cd $PGADMIN_DIR // Get password from secret userPasswordSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ Namespace: pgadmin.Namespace, - Name: user.PasswordRef.LocalObjectReference.Name, + Name: user.PasswordRef.Name, }} err := errors.WithStack( - r.Client.Get(ctx, client.ObjectKeyFromObject(userPasswordSecret), userPasswordSecret)) + r.Get(ctx, client.ObjectKeyFromObject(userPasswordSecret), userPasswordSecret)) if err != nil { log.Error(err, "Could not get user password secret") continue @@ -293,11 +293,7 @@ cd $PGADMIN_DIR // to add a user, that user will not be in intentUsers. If errors occurred when attempting to // update a user, the user will be in intentUsers as it existed before. We now want to marshal the // intentUsers to json and write the users.json file to the secret. - usersJSON, err := json.Marshal(intentUsers) - if err != nil { - return err - } - intentUserSecret.Data["users.json"] = usersJSON + intentUserSecret.Data["users.json"], _ = json.Marshal(intentUsers) err = errors.WithStack(r.setControllerReference(pgadmin, intentUserSecret)) if err == nil { diff --git a/internal/controller/standalone_pgadmin/users_test.go b/internal/controller/standalone_pgadmin/users_test.go index 409fcea701..b164bb6069 100644 --- a/internal/controller/standalone_pgadmin/users_test.go +++ b/internal/controller/standalone_pgadmin/users_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,12 +7,12 @@ package standalone_pgadmin import ( "context" "encoding/json" + "errors" "fmt" "io" "strings" "testing" - "github.com/pkg/errors" "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -112,7 +112,7 @@ func TestReconcilePGAdminUsers(t *testing.T) { // Simulate a v7 version of pgAdmin by setting stdout to "7" for // podexec call in reconcilePGAdminMajorVersion - stdout.Write([]byte("7")) + _, _ = stdout.Write([]byte("7")) return nil } @@ -147,7 +147,7 @@ func TestReconcilePGAdminUsers(t *testing.T) { // Simulate a v7 version of pgAdmin by setting stdout to "7" for // podexec call in reconcilePGAdminMajorVersion - stdout.Write([]byte("7")) + _, _ = stdout.Write([]byte("7")) return nil } @@ -182,7 +182,7 @@ func TestReconcilePGAdminMajorVersion(t *testing.T) { // Simulate a v7 version of pgAdmin by setting stdout to "7" for // podexec call in reconcilePGAdminMajorVersion - stdout.Write([]byte("7")) + _, _ = stdout.Write([]byte("7")) return nil } @@ -197,7 +197,7 @@ func TestReconcilePGAdminMajorVersion(t *testing.T) { stdin io.Reader, stdout, stderr io.Writer, command ...string, ) error { // Simulate the python call giving bad data (not a version int) - stdout.Write([]byte("asdfjkl;")) + _, _ = stdout.Write([]byte("asdfjkl;")) return nil } @@ -236,6 +236,12 @@ func TestWritePGAdminUsers(t *testing.T) { pgadmin := new(v1beta1.PGAdmin) pgadmin.Name = "test-standalone-pgadmin" pgadmin.Namespace = ns.Name + require.UnmarshalInto(t, &pgadmin.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, + }, + }`) assert.NilError(t, cc.Create(ctx, pgadmin)) userPasswordSecret1 := &corev1.Secret{ @@ -310,8 +316,8 @@ func TestWritePGAdminUsers(t *testing.T) { assert.Equal(t, calls, 1, "PodExec should be called once") secret := &corev1.Secret{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -370,8 +376,8 @@ func TestWritePGAdminUsers(t *testing.T) { assert.Equal(t, updateUserCalls, 1, "The update-user command should be executed once") secret := &corev1.Secret{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -442,8 +448,8 @@ func TestWritePGAdminUsers(t *testing.T) { assert.Equal(t, updateUserCalls, 1, "The update-user command should be executed once") secret := &corev1.Secret{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -487,8 +493,8 @@ func TestWritePGAdminUsers(t *testing.T) { assert.Equal(t, calls, 0, "PodExec should be called zero times") secret := &corev1.Secret{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -529,8 +535,8 @@ func TestWritePGAdminUsers(t *testing.T) { // User in users.json should be unchanged secret := &corev1.Secret{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -547,7 +553,7 @@ func TestWritePGAdminUsers(t *testing.T) { ) error { calls++ - stderr.Write([]byte("issue running setup.py update-user command")) + _, _ = stderr.Write([]byte("issue running setup.py update-user command")) return nil } @@ -556,8 +562,8 @@ func TestWritePGAdminUsers(t *testing.T) { assert.Equal(t, calls, 2, "PodExec should be called once more") // User in users.json should be unchanged - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -609,8 +615,8 @@ func TestWritePGAdminUsers(t *testing.T) { // User in users.json should be unchanged and attempt to add user should not // have succeeded secret := &corev1.Secret{ObjectMeta: naming.StandalonePGAdmin(pgadmin)} - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -627,7 +633,7 @@ func TestWritePGAdminUsers(t *testing.T) { ) error { calls++ - stderr.Write([]byte("issue running setup.py add-user command")) + _, _ = stderr.Write([]byte("issue running setup.py add-user command")) return nil } @@ -637,8 +643,8 @@ func TestWritePGAdminUsers(t *testing.T) { // User in users.json should be unchanged and attempt to add user should not // have succeeded - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -655,7 +661,7 @@ func TestWritePGAdminUsers(t *testing.T) { ) error { calls++ - stdout.Write([]byte("Invalid email address")) + _, _ = stdout.Write([]byte("Invalid email address")) return nil } @@ -665,8 +671,8 @@ func TestWritePGAdminUsers(t *testing.T) { // User in users.json should be unchanged and attempt to add user should not // have succeeded - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) @@ -684,7 +690,7 @@ func TestWritePGAdminUsers(t *testing.T) { ) error { calls++ - stdout.Write([]byte("Password must be at least 6 characters long")) + _, _ = stdout.Write([]byte("Password must be at least 6 characters long")) return nil } @@ -694,8 +700,8 @@ func TestWritePGAdminUsers(t *testing.T) { // User in users.json should be unchanged and attempt to add user should not // have succeeded - assert.NilError(t, errors.WithStack( - reconciler.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret))) + assert.NilError(t, + reconciler.Get(ctx, client.ObjectKeyFromObject(secret), secret)) if assert.Check(t, secret.Data["users.json"] != nil) { var usersArr []pgAdminUserForJson assert.NilError(t, json.Unmarshal(secret.Data["users.json"], &usersArr)) diff --git a/internal/controller/standalone_pgadmin/volume.go b/internal/controller/standalone_pgadmin/volume.go index 7615f6142b..a3e26682ef 100644 --- a/internal/controller/standalone_pgadmin/volume.go +++ b/internal/controller/standalone_pgadmin/volume.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,14 +7,13 @@ package standalone_pgadmin import ( "context" + "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" - "github.com/pkg/errors" - "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -51,7 +50,7 @@ func pvc(pgadmin *v1beta1.PGAdmin) *corev1.PersistentVolumeClaim { pgadmin.Spec.Metadata.GetLabelsOrNil(), naming.StandalonePGAdminDataLabels(pgadmin.Name), ) - pvc.Spec = pgadmin.Spec.DataVolumeClaimSpec + pvc.Spec = pgadmin.Spec.DataVolumeClaimSpec.AsPersistentVolumeClaimSpec() return pvc } diff --git a/internal/controller/standalone_pgadmin/volume_test.go b/internal/controller/standalone_pgadmin/volume_test.go index 645c228277..6500ac6c42 100644 --- a/internal/controller/standalone_pgadmin/volume_test.go +++ b/internal/controller/standalone_pgadmin/volume_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,20 +6,17 @@ package standalone_pgadmin import ( "context" + "errors" "testing" "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/pkg/errors" - "github.com/crunchydata/postgres-operator/internal/controller/runtime" - "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/testing/cmp" "github.com/crunchydata/postgres-operator/internal/testing/events" @@ -38,19 +35,16 @@ func TestReconcilePGAdminDataVolume(t *testing.T) { } ns := setupNamespace(t, cc) - pgadmin := &v1beta1.PGAdmin{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-standalone-pgadmin", - Namespace: ns.Name, + pgadmin := v1beta1.NewPGAdmin() + pgadmin.Namespace = ns.Name + pgadmin.Name = "test-standalone-pgadmin" + require.UnmarshalInto(t, &pgadmin.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, + storageClassName: storage-class-for-data, }, - Spec: v1beta1.PGAdminSpec{ - DataVolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ - AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, - Resources: corev1.VolumeResourceRequirements{ - Requests: map[corev1.ResourceName]resource.Quantity{ - corev1.ResourceStorage: resource.MustParse("1Gi")}}, - StorageClassName: initialize.String("storage-class-for-data"), - }}} + }`) assert.NilError(t, cc.Create(ctx, pgadmin)) t.Cleanup(func() { assert.Check(t, cc.Delete(ctx, pgadmin)) }) diff --git a/internal/controller/standalone_pgadmin/watches.go b/internal/controller/standalone_pgadmin/watches.go deleted file mode 100644 index 49ac1ebd29..0000000000 --- a/internal/controller/standalone_pgadmin/watches.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. -// -// SPDX-License-Identifier: Apache-2.0 - -package standalone_pgadmin - -import ( - "context" - - "k8s.io/client-go/util/workqueue" - 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/handler" - - "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" -) - -// watchPostgresClusters returns a [handler.EventHandler] for PostgresClusters. -func (r *PGAdminReconciler) watchPostgresClusters() handler.Funcs { - handle := func(ctx context.Context, cluster client.Object, q workqueue.RateLimitingInterface) { - for _, pgadmin := range r.findPGAdminsForPostgresCluster(ctx, cluster) { - - q.Add(ctrl.Request{ - NamespacedName: client.ObjectKeyFromObject(pgadmin), - }) - } - } - - return handler.Funcs{ - CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.Object, q) - }, - UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.ObjectNew, q) - }, - DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.Object, q) - }, - } -} - -// watchForRelatedSecret handles create/update/delete events for secrets, -// passing the Secret ObjectKey to findPGAdminsForSecret -func (r *PGAdminReconciler) watchForRelatedSecret() handler.EventHandler { - handle := func(ctx context.Context, secret client.Object, q workqueue.RateLimitingInterface) { - key := client.ObjectKeyFromObject(secret) - - for _, pgadmin := range r.findPGAdminsForSecret(ctx, key) { - q.Add(ctrl.Request{ - NamespacedName: client.ObjectKeyFromObject(pgadmin), - }) - } - } - - return handler.Funcs{ - CreateFunc: func(ctx context.Context, e event.CreateEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.Object, q) - }, - UpdateFunc: func(ctx context.Context, e event.UpdateEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.ObjectNew, q) - }, - // If the secret is deleted, we want to reconcile - // in order to emit an event/status about this problem. - // We will also emit a matching event/status about this problem - // when we reconcile the cluster and can't find the secret. - // That way, users will get two alerts: one when the secret is deleted - // and another when the cluster is being reconciled. - DeleteFunc: func(ctx context.Context, e event.DeleteEvent, q workqueue.RateLimitingInterface) { - handle(ctx, e.Object, q) - }, - } -} - -//+kubebuilder:rbac:groups="postgres-operator.crunchydata.com",resources="pgadmins",verbs={list} - -// findPGAdminsForSecret returns PGAdmins that have a user or users that have their password -// stored in the Secret -func (r *PGAdminReconciler) findPGAdminsForSecret( - ctx context.Context, secret client.ObjectKey, -) []*v1beta1.PGAdmin { - var matching []*v1beta1.PGAdmin - var pgadmins v1beta1.PGAdminList - - // NOTE: If this becomes slow due to a large number of PGAdmins in a single - // namespace, we can configure the [ctrl.Manager] field indexer and pass a - // [fields.Selector] here. - // - https://book.kubebuilder.io/reference/watching-resources/externally-managed.html - if err := r.List(ctx, &pgadmins, &client.ListOptions{ - Namespace: secret.Namespace, - }); err == nil { - for i := range pgadmins.Items { - for j := range pgadmins.Items[i].Spec.Users { - if pgadmins.Items[i].Spec.Users[j].PasswordRef.LocalObjectReference.Name == secret.Name { - matching = append(matching, &pgadmins.Items[i]) - break - } - } - } - } - return matching -} diff --git a/internal/feature/features.go b/internal/feature/features.go index db424ead42..d593c9e394 100644 --- a/internal/feature/features.go +++ b/internal/feature/features.go @@ -1,4 +1,4 @@ -// Copyright 2017 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -42,6 +42,9 @@ package feature import ( "context" + "fmt" + "slices" + "strings" "k8s.io/component-base/featuregate" ) @@ -51,7 +54,6 @@ type Feature = featuregate.Feature // Gate indicates what features exist and which are enabled. type Gate interface { Enabled(Feature) bool - String() string } // MutableGate contains features that can be enabled or disabled. @@ -73,14 +75,24 @@ const ( // Support automatically growing volumes AutoGrowVolumes = "AutoGrowVolumes" + // Deprecated BridgeIdentifiers = "BridgeIdentifiers" // Support custom sidecars for PostgreSQL instance Pods InstanceSidecars = "InstanceSidecars" + // Export logs using OpenTelemetry + OpenTelemetryLogs = "OpenTelemetryLogs" + + // Export metrics using OpenTelemetry + OpenTelemetryMetrics = "OpenTelemetryMetrics" + // Support custom sidecars for pgBouncer Pods PGBouncerSidecars = "PGBouncerSidecars" + // Adjust PGUpgrade parallelism according to CPU resources + PGUpgradeCPUConcurrency = "PGUpgradeCPUConcurrency" + // Support tablespace volumes TablespaceVolumes = "TablespaceVolumes" @@ -93,14 +105,17 @@ func NewGate() MutableGate { gate := featuregate.NewFeatureGate() if err := gate.Add(map[Feature]featuregate.FeatureSpec{ - AppendCustomQueries: {Default: false, PreRelease: featuregate.Alpha}, - AutoCreateUserSchema: {Default: true, PreRelease: featuregate.Beta}, - AutoGrowVolumes: {Default: false, PreRelease: featuregate.Alpha}, - BridgeIdentifiers: {Default: false, PreRelease: featuregate.Alpha}, - InstanceSidecars: {Default: false, PreRelease: featuregate.Alpha}, - PGBouncerSidecars: {Default: false, PreRelease: featuregate.Alpha}, - TablespaceVolumes: {Default: false, PreRelease: featuregate.Alpha}, - VolumeSnapshots: {Default: false, PreRelease: featuregate.Alpha}, + AppendCustomQueries: {Default: false, PreRelease: featuregate.Alpha}, + AutoCreateUserSchema: {Default: true, PreRelease: featuregate.Beta}, + AutoGrowVolumes: {Default: false, PreRelease: featuregate.Alpha}, + BridgeIdentifiers: {Default: false, PreRelease: featuregate.Deprecated}, + InstanceSidecars: {Default: true, PreRelease: featuregate.Alpha}, + OpenTelemetryLogs: {Default: false, PreRelease: featuregate.Alpha}, + OpenTelemetryMetrics: {Default: false, PreRelease: featuregate.Alpha}, + PGBouncerSidecars: {Default: false, PreRelease: featuregate.Alpha}, + PGUpgradeCPUConcurrency: {Default: true, PreRelease: featuregate.Beta}, + TablespaceVolumes: {Default: false, PreRelease: featuregate.Alpha}, + VolumeSnapshots: {Default: false, PreRelease: featuregate.Alpha}, }); err != nil { panic(err) } @@ -122,11 +137,36 @@ func NewContext(ctx context.Context, gate Gate) context.Context { return context.WithValue(ctx, contextKey{}, gate) } -func ShowGates(ctx context.Context) string { - featuresEnabled := "" - gate, ok := ctx.Value(contextKey{}).(Gate) - if ok { - featuresEnabled = gate.String() +// ShowEnabled returns all the features enabled in the Gate contained in ctx. +func ShowEnabled(ctx context.Context) string { + featuresEnabled := []string{} + if gate, ok := ctx.Value(contextKey{}).(interface { + Gate + GetAll() map[Feature]featuregate.FeatureSpec + }); ok { + specs := gate.GetAll() + for feature := range specs { + // `gate.Enabled` first checks if the feature is enabled; + // then (if not explicitly set by the user), + // it checks if the feature is on/true by default + if gate.Enabled(feature) { + featuresEnabled = append(featuresEnabled, fmt.Sprintf("%s=true", feature)) + } + } + } + slices.Sort(featuresEnabled) + return strings.Join(featuresEnabled, ",") +} + +// ShowAssigned returns the features enabled or disabled by Set and SetFromMap +// in the Gate contained in ctx. +func ShowAssigned(ctx context.Context) string { + featuresAssigned := "" + if gate, ok := ctx.Value(contextKey{}).(interface { + Gate + String() string + }); ok { + featuresAssigned = gate.String() } - return featuresEnabled + return featuresAssigned } diff --git a/internal/feature/features_test.go b/internal/feature/features_test.go index f76dd216e6..1ec6c79817 100644 --- a/internal/feature/features_test.go +++ b/internal/feature/features_test.go @@ -1,4 +1,4 @@ -// Copyright 2017 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,6 +6,7 @@ package feature import ( "context" + "strings" "testing" "gotest.tools/v3/assert" @@ -19,12 +20,13 @@ func TestDefaults(t *testing.T) { assert.Assert(t, true == gate.Enabled(AutoCreateUserSchema)) assert.Assert(t, false == gate.Enabled(AutoGrowVolumes)) assert.Assert(t, false == gate.Enabled(BridgeIdentifiers)) - assert.Assert(t, false == gate.Enabled(InstanceSidecars)) + assert.Assert(t, true == gate.Enabled(InstanceSidecars)) + assert.Assert(t, false == gate.Enabled(OpenTelemetryLogs)) + assert.Assert(t, false == gate.Enabled(OpenTelemetryMetrics)) assert.Assert(t, false == gate.Enabled(PGBouncerSidecars)) + assert.Assert(t, true == gate.Enabled(PGUpgradeCPUConcurrency)) assert.Assert(t, false == gate.Enabled(TablespaceVolumes)) assert.Assert(t, false == gate.Enabled(VolumeSnapshots)) - - assert.Equal(t, gate.String(), "") } func TestStringFormat(t *testing.T) { @@ -33,7 +35,6 @@ func TestStringFormat(t *testing.T) { assert.NilError(t, gate.Set("")) assert.NilError(t, gate.Set("TablespaceVolumes=true")) - assert.Equal(t, gate.String(), "TablespaceVolumes=true") assert.Assert(t, true == gate.Enabled(TablespaceVolumes)) err := gate.Set("NotAGate=true") @@ -53,13 +54,21 @@ func TestContext(t *testing.T) { t.Parallel() gate := NewGate() ctx := NewContext(context.Background(), gate) - assert.Equal(t, ShowGates(ctx), "") + + assert.Equal(t, ShowAssigned(ctx), "") + assert.Assert(t, ShowEnabled(ctx) != "") // This assumes some feature is enabled by default. assert.NilError(t, gate.Set("TablespaceVolumes=true")) - assert.Assert(t, true == Enabled(ctx, TablespaceVolumes)) - assert.Equal(t, ShowGates(ctx), "TablespaceVolumes=true") + assert.Assert(t, Enabled(ctx, TablespaceVolumes)) + assert.Equal(t, ShowAssigned(ctx), "TablespaceVolumes=true") + assert.Assert(t, + strings.Contains(ShowEnabled(ctx), "TablespaceVolumes=true"), + "got: %v", ShowEnabled(ctx)) assert.NilError(t, gate.SetFromMap(map[string]bool{TablespaceVolumes: false})) - assert.Assert(t, false == Enabled(ctx, TablespaceVolumes)) - assert.Equal(t, ShowGates(ctx), "TablespaceVolumes=false") + assert.Assert(t, !Enabled(ctx, TablespaceVolumes)) + assert.Equal(t, ShowAssigned(ctx), "TablespaceVolumes=false") + assert.Assert(t, + !strings.Contains(ShowEnabled(ctx), "TablespaceVolumes"), + "got: %v", ShowEnabled(ctx)) } diff --git a/internal/initialize/doc.go b/internal/initialize/doc.go index aedd85846f..cd1d277e73 100644 --- a/internal/initialize/doc.go +++ b/internal/initialize/doc.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/initialize/metadata.go b/internal/initialize/metadata.go index d62530736a..dac9ce306c 100644 --- a/internal/initialize/metadata.go +++ b/internal/initialize/metadata.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/initialize/metadata_test.go b/internal/initialize/metadata_test.go index 735e455a2e..8afbed4ad5 100644 --- a/internal/initialize/metadata_test.go +++ b/internal/initialize/metadata_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/initialize/primitives.go b/internal/initialize/primitives.go index 9bc264f88c..c145813247 100644 --- a/internal/initialize/primitives.go +++ b/internal/initialize/primitives.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -35,5 +35,14 @@ func Map[M ~map[K]V, K comparable, V any](m *M) { // Pointer returns a pointer to v. func Pointer[T any](v T) *T { return &v } +// Pointers returns a slice of pointers to the items in v. +func Pointers[T any](v ...T) []*T { + p := make([]*T, len(v)) + for i := range v { + p[i] = &v[i] + } + return p +} + // String returns a pointer to v. func String(v string) *string { return &v } diff --git a/internal/initialize/primitives_test.go b/internal/initialize/primitives_test.go index e39898b4fe..38f9b1ce6c 100644 --- a/internal/initialize/primitives_test.go +++ b/internal/initialize/primitives_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -190,6 +190,60 @@ func TestPointer(t *testing.T) { }) } +func TestPointers(t *testing.T) { + t.Run("arguments", func(t *testing.T) { + assert.Assert(t, nil != initialize.Pointers[int](), "does not return nil slice") + assert.DeepEqual(t, []*int{}, initialize.Pointers[int]()) + + s1 := initialize.Pointers(0, -99, 42) + if assert.Check(t, len(s1) == 3, "got %#v", s1) { + if assert.Check(t, s1[0] != nil) { + assert.Equal(t, *s1[0], 0) + } + if assert.Check(t, s1[1] != nil) { + assert.Equal(t, *s1[1], -99) + } + if assert.Check(t, s1[2] != nil) { + assert.Equal(t, *s1[2], 42) + } + } + + // Values are the same, but pointers differ. + s2 := initialize.Pointers(0, -99, 42) + assert.DeepEqual(t, s1, s2) + assert.Assert(t, s1[0] != s2[0]) + assert.Assert(t, s1[1] != s2[1]) + assert.Assert(t, s1[2] != s2[2]) + }) + + t.Run("slice", func(t *testing.T) { + var z []string + assert.Assert(t, nil != initialize.Pointers(z...), "does not return nil slice") + assert.DeepEqual(t, []*string{}, initialize.Pointers(z...)) + + v := []string{"doot", "", "baz"} + s1 := initialize.Pointers(v...) + if assert.Check(t, len(s1) == 3, "got %#v", s1) { + if assert.Check(t, s1[0] != nil) { + assert.Equal(t, *s1[0], "doot") + } + if assert.Check(t, s1[1] != nil) { + assert.Equal(t, *s1[1], "") + } + if assert.Check(t, s1[2] != nil) { + assert.Equal(t, *s1[2], "baz") + } + } + + // Values and pointers are the same. + s2 := initialize.Pointers(v...) + assert.DeepEqual(t, s1, s2) + assert.Assert(t, s1[0] == s2[0]) + assert.Assert(t, s1[1] == s2[1]) + assert.Assert(t, s1[2] == s2[2]) + }) +} + func TestString(t *testing.T) { z := initialize.String("") if assert.Check(t, z != nil) { diff --git a/internal/initialize/security.go b/internal/initialize/security.go index 5dd52d7b1e..f7b451f502 100644 --- a/internal/initialize/security.go +++ b/internal/initialize/security.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/initialize/security_test.go b/internal/initialize/security_test.go index 0a6409cf41..6573143be6 100644 --- a/internal/initialize/security_test.go +++ b/internal/initialize/security_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/kubeapi/patch.go b/internal/kubeapi/patch.go index 973852c17a..95bcc9a6e1 100644 --- a/internal/kubeapi/patch.go +++ b/internal/kubeapi/patch.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -18,12 +18,10 @@ var escapeJSONPointer = strings.NewReplacer( "/", "~1", ).Replace -// JSON6902 represents a JSON Patch according to RFC 6902; the same as -// k8s.io/apimachinery/pkg/types.JSONPatchType. -type JSON6902 []interface{} +// JSON6902 represents a JSON Patch according to RFC 6902; the same as [types.JSONPatchType]. +type JSON6902 []any -// NewJSONPatch creates a new JSON Patch according to RFC 6902; the same as -// k8s.io/apimachinery/pkg/types.JSONPatchType. +// NewJSONPatch creates a new JSON Patch according to RFC 6902; the same as [types.JSONPatchType]. func NewJSONPatch() *JSON6902 { return &JSON6902{} } func (*JSON6902) pointer(tokens ...string) string { @@ -50,10 +48,10 @@ func (*JSON6902) pointer(tokens ...string) string { // > // > o If the target location specifies an object member that does exist, // > that member's value is replaced. -func (patch *JSON6902) Add(path ...string) func(value interface{}) *JSON6902 { +func (patch *JSON6902) Add(path ...string) func(value any) *JSON6902 { i := len(*patch) - f := func(value interface{}) *JSON6902 { - (*patch)[i] = map[string]interface{}{ + f := func(value any) *JSON6902 { + (*patch)[i] = map[string]any{ "op": "add", "path": patch.pointer(path...), "value": value, @@ -72,7 +70,7 @@ func (patch *JSON6902) Add(path ...string) func(value interface{}) *JSON6902 { // > // > The target location MUST exist for the operation to be successful. func (patch *JSON6902) Remove(path ...string) *JSON6902 { - *patch = append(*patch, map[string]interface{}{ + *patch = append(*patch, map[string]any{ "op": "remove", "path": patch.pointer(path...), }) @@ -86,10 +84,10 @@ func (patch *JSON6902) Remove(path ...string) *JSON6902 { // > with a new value. // > // > The target location MUST exist for the operation to be successful. -func (patch *JSON6902) Replace(path ...string) func(value interface{}) *JSON6902 { +func (patch *JSON6902) Replace(path ...string) func(value any) *JSON6902 { i := len(*patch) - f := func(value interface{}) *JSON6902 { - (*patch)[i] = map[string]interface{}{ + f := func(value any) *JSON6902 { + (*patch)[i] = map[string]any{ "op": "replace", "path": patch.pointer(path...), "value": value, @@ -103,23 +101,21 @@ func (patch *JSON6902) Replace(path ...string) func(value interface{}) *JSON6902 } // Bytes returns the JSON representation of patch. -func (patch JSON6902) Bytes() ([]byte, error) { return patch.Data(nil) } +func (patch *JSON6902) Bytes() ([]byte, error) { return patch.Data(nil) } // Data returns the JSON representation of patch. -func (patch JSON6902) Data(client.Object) ([]byte, error) { return json.Marshal(patch) } +func (patch *JSON6902) Data(client.Object) ([]byte, error) { return json.Marshal(*patch) } // IsEmpty returns true when patch has no operations. -func (patch JSON6902) IsEmpty() bool { return len(patch) == 0 } +func (patch *JSON6902) IsEmpty() bool { return len(*patch) == 0 } -// Type returns k8s.io/apimachinery/pkg/types.JSONPatchType. -func (patch JSON6902) Type() types.PatchType { return types.JSONPatchType } +// Type returns [types.JSONPatchType]. +func (patch *JSON6902) Type() types.PatchType { return types.JSONPatchType } -// Merge7386 represents a JSON Merge Patch according to RFC 7386; the same as -// k8s.io/apimachinery/pkg/types.MergePatchType. -type Merge7386 map[string]interface{} +// Merge7386 represents a JSON Merge Patch according to RFC 7386; the same as [types.MergePatchType]. +type Merge7386 map[string]any -// NewMergePatch creates a new JSON Merge Patch according to RFC 7386; the same -// as k8s.io/apimachinery/pkg/types.MergePatchType. +// NewMergePatch creates a new JSON Merge Patch according to RFC 7386; the same as [types.MergePatchType]. func NewMergePatch() *Merge7386 { return &Merge7386{} } // Add modifies patch to indicate that the member at path should be added or @@ -130,7 +126,7 @@ func NewMergePatch() *Merge7386 { return &Merge7386{} } // > contain the member, the value is replaced. Null values in the merge // > patch are given special meaning to indicate the removal of existing // > values in the target. -func (patch *Merge7386) Add(path ...string) func(value interface{}) *Merge7386 { +func (patch *Merge7386) Add(path ...string) func(value any) *Merge7386 { position := *patch for len(path) > 1 { @@ -145,10 +141,10 @@ func (patch *Merge7386) Add(path ...string) func(value interface{}) *Merge7386 { } if len(path) < 1 { - return func(interface{}) *Merge7386 { return patch } + return func(any) *Merge7386 { return patch } } - f := func(value interface{}) *Merge7386 { + f := func(value any) *Merge7386 { position[path[0]] = value return patch } @@ -165,13 +161,13 @@ func (patch *Merge7386) Remove(path ...string) *Merge7386 { } // Bytes returns the JSON representation of patch. -func (patch Merge7386) Bytes() ([]byte, error) { return patch.Data(nil) } +func (patch *Merge7386) Bytes() ([]byte, error) { return patch.Data(nil) } // Data returns the JSON representation of patch. -func (patch Merge7386) Data(client.Object) ([]byte, error) { return json.Marshal(patch) } +func (patch *Merge7386) Data(client.Object) ([]byte, error) { return json.Marshal(*patch) } // IsEmpty returns true when patch has no modifications. -func (patch Merge7386) IsEmpty() bool { return len(patch) == 0 } +func (patch *Merge7386) IsEmpty() bool { return len(*patch) == 0 } -// Type returns k8s.io/apimachinery/pkg/types.MergePatchType. -func (patch Merge7386) Type() types.PatchType { return types.MergePatchType } +// Type returns [types.MergePatchType]. +func (patch *Merge7386) Type() types.PatchType { return types.MergePatchType } diff --git a/internal/kubeapi/patch_test.go b/internal/kubeapi/patch_test.go index 52f5787b8f..05bd140066 100644 --- a/internal/kubeapi/patch_test.go +++ b/internal/kubeapi/patch_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,10 +13,10 @@ import ( "k8s.io/apimachinery/pkg/types" ) -func assertJSON(t testing.TB, expected interface{}, actual []byte) { +func assertJSON(t testing.TB, expected any, actual []byte) { t.Helper() - var e, a interface{} + var e, a any var err error if b, ok := expected.([]byte); ok { @@ -248,7 +248,7 @@ func TestMerge7386Equivalence(t *testing.T) { // one call using other types NewMergePatch(). - Add("metadata")(map[string]interface{}{ + Add("metadata")(map[string]any{ "labels": labels.Set{"lk": "lv"}, "annotations": map[string]string{"ak1": "av1", "ak2": "av2"}, }), diff --git a/internal/kubernetes/apis.go b/internal/kubernetes/apis.go new file mode 100644 index 0000000000..01d1f988a6 --- /dev/null +++ b/internal/kubernetes/apis.go @@ -0,0 +1,60 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package kubernetes + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/sets" +) + +// API is a combination of Group, Version, and Kind that can be used to check +// what is available in the Kubernetes API. There are four ways to populate it: +// 1. Group without Version nor Kind means any resource in that Group. +// 2. Group with Version but no Kind means any resource in that GV. +// 3. Group with Kind but no Version means that Kind in any Version of the Group. +// 4. Group with Version and Kind means that exact GVK. +type API = schema.GroupVersionKind + +type APIs interface { + Has(API) bool + HasAll(...API) bool + HasAny(...API) bool +} + +// APISet implements [APIs] using empty struct for minimal memory consumption. +type APISet = sets.Set[API] + +func NewAPISet(api ...API) APISet { + // Start with everything that's passed in; full GVKs are here. + s := sets.New(api...) + + // Add the other combinations; Group, GV, and GK. + for i := range api { + s.Insert( + API{Group: api[i].Group}, + API{Group: api[i].Group, Version: api[i].Version}, + API{Group: api[i].Group, Kind: api[i].Kind}, + ) + } + + return s +} + +type apiContextKey struct{} + +// Has returns true when api was previously stored by [NewAPIContext]. +func Has(ctx context.Context, api API) bool { + if i, ok := ctx.Value(apiContextKey{}).(interface{ Has(API) bool }); ok { + return i.Has(api) + } + return false +} + +// NewAPIContext returns a copy of ctx containing apis. Interrogate it using [Has]. +func NewAPIContext(ctx context.Context, apis APIs) context.Context { + return context.WithValue(ctx, apiContextKey{}, apis) +} diff --git a/internal/kubernetes/apis_test.go b/internal/kubernetes/apis_test.go new file mode 100644 index 0000000000..4126569f98 --- /dev/null +++ b/internal/kubernetes/apis_test.go @@ -0,0 +1,66 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package kubernetes + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" +) + +func TestAPISet(t *testing.T) { + t.Parallel() + + var zero APISet + assert.Assert(t, !zero.Has(API{Group: "security.openshift.io"})) + assert.Assert(t, !zero.Has(API{Group: "security.openshift.io", Kind: "SecurityContextConstraints"})) + assert.Assert(t, !zero.HasAll(API{Group: "security.openshift.io"}, API{Group: "snapshot.storage.k8s.io"})) + assert.Assert(t, !zero.HasAny(API{Group: "security.openshift.io"}, API{Group: "snapshot.storage.k8s.io"})) + + empty := NewAPISet() + assert.Assert(t, !empty.Has(API{Group: "security.openshift.io"})) + assert.Assert(t, !empty.Has(API{Group: "security.openshift.io", Kind: "SecurityContextConstraints"})) + + one := NewAPISet( + API{Group: "security.openshift.io", Kind: "SecurityContextConstraints"}, + ) + assert.Assert(t, one.Has(API{Group: "security.openshift.io"})) + assert.Assert(t, one.Has(API{Group: "security.openshift.io", Kind: "SecurityContextConstraints"})) + assert.Assert(t, !one.HasAll(API{Group: "snapshot.storage.k8s.io"}, API{Group: "security.openshift.io"})) + assert.Assert(t, !one.HasAny(API{Group: "snapshot.storage.k8s.io"})) + assert.Assert(t, one.HasAny(API{Group: "snapshot.storage.k8s.io"}, API{Group: "security.openshift.io"})) + + two := NewAPISet( + API{Group: "security.openshift.io", Kind: "SecurityContextConstraints"}, + API{Group: "snapshot.storage.k8s.io", Kind: "VolumeSnapshot"}, + ) + assert.Assert(t, two.Has(API{Group: "security.openshift.io"})) + assert.Assert(t, two.Has(API{Group: "snapshot.storage.k8s.io"})) + assert.Assert(t, two.HasAll(API{Group: "snapshot.storage.k8s.io"}, API{Group: "security.openshift.io"})) + assert.Assert(t, two.HasAny(API{Group: "snapshot.storage.k8s.io"})) + assert.Assert(t, two.HasAny(API{Group: "snapshot.storage.k8s.io"}, API{Group: "security.openshift.io"})) +} + +func TestAPIContext(t *testing.T) { + t.Parallel() + + // The background context always return false. + ctx := context.Background() + + assert.Assert(t, !Has(ctx, API{Group: "security.openshift.io"})) + assert.Assert(t, !Has(ctx, API{Group: "snapshot.storage.k8s.io"})) + + // An initialized context returns what is stored. + set := NewAPISet(API{Group: "security.openshift.io", Kind: "SecurityContextConstraints"}) + ctx = NewAPIContext(ctx, set) + + assert.Assert(t, Has(ctx, API{Group: "security.openshift.io"})) + assert.Assert(t, !Has(ctx, API{Group: "snapshot.storage.k8s.io"})) + + // The stored value is mutable within the context. + set[API{Group: "snapshot.storage.k8s.io"}] = struct{}{} + assert.Assert(t, Has(ctx, API{Group: "snapshot.storage.k8s.io"})) +} diff --git a/internal/kubernetes/discovery.go b/internal/kubernetes/discovery.go new file mode 100644 index 0000000000..62e14fe496 --- /dev/null +++ b/internal/kubernetes/discovery.go @@ -0,0 +1,229 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package kubernetes + +import ( + "context" + "errors" + "sync" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/version" + "k8s.io/client-go/discovery" + "k8s.io/client-go/rest" + + "github.com/crunchydata/postgres-operator/internal/logging" +) + +type Version = version.Info + +// DiscoveryRunner implements [APIs] by reading from a Kubernetes API client. +// Its methods are safe to call concurrently. +type DiscoveryRunner struct { + // NOTE(tracing): The methods of [discovery.DiscoveryClient] do not take + // a Context so their API calls won't have a parent span. + // - https://issue.k8s.io/126379 + Client interface { + ServerGroups() (*metav1.APIGroupList, error) + ServerResourcesForGroupVersion(string) (*metav1.APIResourceList, error) + ServerVersion() (*version.Info, error) + } + + refresh time.Duration + + // relevant is the list of APIs to examine during Read. + // Has, HasAll, and HasAny return false when this is empty. + relevant []API + + have struct { + sync.RWMutex + APISet + Version + } +} + +// NewDiscoveryRunner creates a [DiscoveryRunner] that periodically reads from +// the Kubernetes at config. +func NewDiscoveryRunner(config *rest.Config) (*DiscoveryRunner, error) { + dc, err := discovery.NewDiscoveryClientForConfig(config) + + runner := &DiscoveryRunner{ + Client: dc, + refresh: 10 * time.Minute, + relevant: []API{ + // https://cert-manager.io/docs/usage/certificate + // https://cert-manager.io/docs/trust/trust-manager + {Group: "cert-manager.io", Kind: "Certificate"}, + {Group: "trust.cert-manager.io", Kind: "Bundle"}, + + // https://gateway-api.sigs.k8s.io/api-types/referencegrant + // https://kep.k8s.io/3766 + {Group: "gateway.networking.k8s.io", Kind: "ReferenceGrant"}, + + // https://docs.openshift.com/container-platform/latest/authentication/managing-security-context-constraints.html + {Group: "security.openshift.io", Kind: "SecurityContextConstraints"}, + + // https://docs.k8s.io/concepts/storage/volume-snapshots + {Group: "snapshot.storage.k8s.io", Kind: "VolumeSnapshot"}, + }, + } + + return runner, err +} + +// Has returns true when api is available in Kuberentes. +func (r *DiscoveryRunner) Has(api API) bool { return r.HasAny(api) } + +// HasAll returns true when every api is available in Kubernetes. +func (r *DiscoveryRunner) HasAll(api ...API) bool { + r.have.RLock() + defer r.have.RUnlock() + return r.have.HasAll(api...) +} + +// HasAny returns true when at least one api is available in Kubernetes. +func (r *DiscoveryRunner) HasAny(api ...API) bool { + r.have.RLock() + defer r.have.RUnlock() + return r.have.HasAny(api...) +} + +// IsOpenShift returns true if this Kubernetes might be OpenShift. The result +// may not be accurate. +func (r *DiscoveryRunner) IsOpenShift() bool { + return r.Has(API{Group: "security.openshift.io", Kind: "SecurityContextConstraints"}) +} + +// NeedLeaderElection returns false so that r runs on any [manager.Manager], +// regardless of which is elected leader in the Kubernetes namespace. +func (r *DiscoveryRunner) NeedLeaderElection() bool { return false } + +// Read fetches available APIs from Kubernetes. +func (r *DiscoveryRunner) Read(ctx context.Context) error { + return errors.Join(r.readAPIs(ctx), r.readVersion()) +} + +func (r *DiscoveryRunner) readAPIs(ctx context.Context) error { + // Build an index of the APIs we want to know about. + wantAPIs := make(map[string]map[string]sets.Set[string]) + for _, want := range r.relevant { + if wantAPIs[want.Group] == nil { + wantAPIs[want.Group] = make(map[string]sets.Set[string]) + } + if wantAPIs[want.Group][want.Version] == nil { + wantAPIs[want.Group][want.Version] = sets.New[string]() + } + if want.Kind != "" { + wantAPIs[want.Group][want.Version].Insert(want.Kind) + } + } + + // Fetch Groups and Versions from Kubernetes. + groups, err := r.Client.ServerGroups() + if err != nil { + return err + } + + // Build an index of the Groups and GVs available in Kubernetes; + // add GK and GVK for resources that we want to know about. + haveAPIs := make(APISet) + for _, apiG := range groups.Groups { + haveG := apiG.Name + haveAPIs.Insert(API{Group: haveG}) + + for _, apiGV := range apiG.Versions { + haveV := apiGV.Version + haveAPIs.Insert(API{Group: haveG, Version: haveV}) + + // Only fetch Resources when there are Kinds we want to know about. + if wantAPIs[haveG][""].Len() == 0 && wantAPIs[haveG][haveV].Len() == 0 { + continue + } + + resources, err := r.Client.ServerResourcesForGroupVersion(apiGV.GroupVersion) + if err != nil { + return err + } + + for _, apiR := range resources.APIResources { + haveK := apiR.Kind + haveAPIs.Insert( + API{Group: haveG, Kind: haveK}, + API{Group: haveG, Kind: haveK, Version: haveV}, + ) + } + } + } + + r.have.Lock() + r.have.APISet = haveAPIs + r.have.Unlock() + + r.have.RLock() + defer r.have.RUnlock() + logging.FromContext(ctx).V(1).Info("Found APIs", "index_size", r.have.Len()) + + return nil +} + +func (r *DiscoveryRunner) readVersion() error { + info, err := r.Client.ServerVersion() + + if info != nil && err == nil { + r.have.Lock() + r.have.Version = *info + r.have.Unlock() + } + + return err +} + +// Start periodically reads the Kuberentes API. It blocks until ctx is cancelled. +func (r *DiscoveryRunner) Start(ctx context.Context) error { + ticker := time.NewTicker(r.refresh) + defer ticker.Stop() + + log := logging.FromContext(ctx).WithValues("controller", "kubernetes") + ctx = logging.NewContext(ctx, log) + + for { + select { + case <-ticker.C: + if err := r.Read(ctx); err != nil { + log.Error(err, "Unable to detect Kubernetes APIs") + } + case <-ctx.Done(): + return ctx.Err() + } + } +} + +// Version returns the detected version of Kubernetes. +func (r *DiscoveryRunner) Version() Version { + r.have.RLock() + defer r.have.RUnlock() + return r.have.Version +} + +// IsOpenShift returns true if the detected Kubernetes might be OpenShift. +// The result may not be accurate. When possible, use another technique to +// detect specific behavior. Use [Has] to check for specific APIs. +func IsOpenShift(ctx context.Context) bool { + if i, ok := ctx.Value(apiContextKey{}).(interface{ IsOpenShift() bool }); ok { + return i.IsOpenShift() + } + return false +} + +// VersionString returns a textual representation of the detected Kubernetes +// version, if any. +func VersionString(ctx context.Context) string { + if i, ok := ctx.Value(apiContextKey{}).(interface{ Version() Version }); ok { + return i.Version().String() + } + return "" +} diff --git a/internal/kubernetes/discovery_test.go b/internal/kubernetes/discovery_test.go new file mode 100644 index 0000000000..edbbc1bb94 --- /dev/null +++ b/internal/kubernetes/discovery_test.go @@ -0,0 +1,78 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package kubernetes + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + "k8s.io/apimachinery/pkg/version" + "sigs.k8s.io/controller-runtime/pkg/manager" + + "github.com/crunchydata/postgres-operator/internal/testing/require" +) + +func TestDiscoveryRunnerInterfaces(t *testing.T) { + var _ APIs = new(DiscoveryRunner) + var _ manager.Runnable = new(DiscoveryRunner) + + var runnable manager.LeaderElectionRunnable = new(DiscoveryRunner) + assert.Assert(t, false == runnable.NeedLeaderElection()) +} + +func TestDiscoveryRunnerAPIs(t *testing.T) { + ctx := context.Background() + cfg, _ := require.Kubernetes2(t) + require.ParallelCapacity(t, 0) + + runner, err := NewDiscoveryRunner(cfg) + assert.NilError(t, err) + + // Search for an API that should always exist. + runner.relevant = append(runner.relevant, API{Kind: "Pod"}) + assert.NilError(t, runner.readAPIs(ctx)) + + assert.Assert(t, runner.Has(API{Kind: "Pod"})) + assert.Assert(t, runner.HasAll(API{Kind: "Pod"}, API{Kind: "Secret"})) + assert.Assert(t, runner.HasAny(API{Kind: "Pod"}, API{Kind: "NotGonnaExist"})) + assert.Assert(t, !runner.Has(API{Kind: "NotGonnaExist"})) +} + +func TestDiscoveryRunnerVersion(t *testing.T) { + cfg, _ := require.Kubernetes2(t) + require.ParallelCapacity(t, 0) + + runner, err := NewDiscoveryRunner(cfg) + assert.NilError(t, err) + assert.NilError(t, runner.readVersion()) + + version := runner.Version() + assert.Assert(t, version.Major != "", "got %#v", version) + assert.Assert(t, version.Minor != "", "got %#v", version) + assert.Assert(t, version.String() != "", "got %q", version.String()) +} + +func TestIsOpenShift(t *testing.T) { + ctx := context.Background() + assert.Assert(t, !IsOpenShift(ctx)) + + runner := new(DiscoveryRunner) + runner.have.APISet = NewAPISet( + API{Group: "security.openshift.io", Kind: "SecurityContextConstraints"}, + ) + assert.Assert(t, IsOpenShift(NewAPIContext(ctx, runner))) +} + +func TestVersionString(t *testing.T) { + ctx := context.Background() + assert.Equal(t, "", VersionString(ctx)) + + runner := new(DiscoveryRunner) + runner.have.Version = version.Info{ + Major: "1", Minor: "2", GitVersion: "asdf", + } + assert.Equal(t, "asdf", VersionString(NewAPIContext(ctx, runner))) +} diff --git a/internal/logging/logr.go b/internal/logging/logr.go index c907997d40..21592f61c0 100644 --- a/internal/logging/logr.go +++ b/internal/logging/logr.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -37,10 +37,12 @@ func FromContext(ctx context.Context) Logger { } // Add trace context, if any, according to OpenTelemetry recommendations. - // Omit trace flags for now because they don't seem relevant. - // - https://github.com/open-telemetry/opentelemetry-specification/blob/v0.7.0/specification/logs/overview.md + // - https://github.com/open-telemetry/opentelemetry-specification/blob/v1.39.0/specification/compatibility/logging_trace_context.md if sc := trace.SpanFromContext(ctx).SpanContext(); sc.IsValid() { - log = log.WithValues("spanid", sc.SpanID(), "traceid", sc.TraceID()) + log = log.WithValues( + "span_id", sc.SpanID(), + "trace_id", sc.TraceID(), + "trace_flags", sc.TraceFlags()) } return log @@ -51,12 +53,12 @@ type sink struct { depth int verbosity int names []string - values []interface{} + values []any // TODO(cbandy): add names or frame to the functions below. - fnError func(error, string, ...interface{}) - fnInfo func(int, string, ...interface{}) + fnError func(error, string, ...any) + fnInfo func(int, string, ...any) } var _ logr.LogSink = (*sink)(nil) @@ -64,7 +66,7 @@ var _ logr.LogSink = (*sink)(nil) func (s *sink) Enabled(level int) bool { return level <= s.verbosity } func (s *sink) Init(info logr.RuntimeInfo) { s.depth = info.CallDepth } -func (s sink) combineValues(kv ...interface{}) []interface{} { +func (s *sink) combineValues(kv ...any) []any { if len(kv) == 0 { return s.values } @@ -74,11 +76,11 @@ func (s sink) combineValues(kv ...interface{}) []interface{} { return kv } -func (s *sink) Error(err error, msg string, kv ...interface{}) { +func (s *sink) Error(err error, msg string, kv ...any) { s.fnError(err, msg, s.combineValues(kv...)...) } -func (s *sink) Info(level int, msg string, kv ...interface{}) { +func (s *sink) Info(level int, msg string, kv ...any) { s.fnInfo(level, msg, s.combineValues(kv...)...) } @@ -89,7 +91,7 @@ func (s *sink) WithName(name string) logr.LogSink { return &out } -func (s *sink) WithValues(kv ...interface{}) logr.LogSink { +func (s *sink) WithValues(kv ...any) logr.LogSink { n := len(s.values) out := *s out.values = append(out.values[:n:n], kv...) diff --git a/internal/logging/logr_test.go b/internal/logging/logr_test.go index 1cbc818ad9..1816fa7f82 100644 --- a/internal/logging/logr_test.go +++ b/internal/logging/logr_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -31,11 +31,11 @@ func TestFromContext(t *testing.T) { } func TestFromContextTraceContext(t *testing.T) { - var calls []map[string]interface{} + var calls []map[string]any SetLogSink(&sink{ - fnInfo: func(_ int, _ string, kv ...interface{}) { - m := make(map[string]interface{}) + fnInfo: func(_ int, _ string, kv ...any) { + m := make(map[string]any) for i := 0; i < len(kv); i += 2 { m[kv[i].(string)] = kv[i+1] } @@ -47,23 +47,23 @@ func TestFromContextTraceContext(t *testing.T) { // Nothing when there's no trace. FromContext(ctx).Info("") - assert.Equal(t, calls[0]["spanid"], nil) - assert.Equal(t, calls[0]["traceid"], nil) + assert.Equal(t, calls[0]["span_id"], nil) + assert.Equal(t, calls[0]["trace_id"], nil) ctx, span := trace.NewTracerProvider().Tracer("").Start(ctx, "test-span") defer span.End() // OpenTelemetry trace context when there is. FromContext(ctx).Info("") - assert.Equal(t, calls[1]["spanid"], span.SpanContext().SpanID()) - assert.Equal(t, calls[1]["traceid"], span.SpanContext().TraceID()) + assert.Equal(t, calls[1]["span_id"], span.SpanContext().SpanID()) + assert.Equal(t, calls[1]["trace_id"], span.SpanContext().TraceID()) } func TestSetLogSink(t *testing.T) { var calls []string SetLogSink(&sink{ - fnInfo: func(_ int, m string, _ ...interface{}) { + fnInfo: func(_ int, m string, _ ...any) { calls = append(calls, m) }, }) diff --git a/internal/logging/logrus.go b/internal/logging/logrus.go index 9683a104d1..6cdea3b06e 100644 --- a/internal/logging/logrus.go +++ b/internal/logging/logrus.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -38,7 +38,7 @@ func Logrus(out io.Writer, version string, debug, verbosity int) logr.LogSink { return &sink{ verbosity: verbosity, - fnError: func(err error, message string, kv ...interface{}) { + fnError: func(err error, message string, kv ...any) { entry := root.WithField("version", version) entry = logrusFields(entry, kv...) @@ -57,7 +57,7 @@ func Logrus(out io.Writer, version string, debug, verbosity int) logr.LogSink { entry.Log(logrus.ErrorLevel, message) }, - fnInfo: func(level int, message string, kv ...interface{}) { + fnInfo: func(level int, message string, kv ...any) { entry := root.WithField("version", version) entry = logrusFields(entry, kv...) @@ -72,7 +72,7 @@ func Logrus(out io.Writer, version string, debug, verbosity int) logr.LogSink { // logrusFields structures and adds the key/value interface to the logrus.Entry; // for instance, if a key is not a string, this formats the key as a string. -func logrusFields(entry *logrus.Entry, kv ...interface{}) *logrus.Entry { +func logrusFields(entry *logrus.Entry, kv ...any) *logrus.Entry { if len(kv) == 0 { return entry } @@ -80,7 +80,7 @@ func logrusFields(entry *logrus.Entry, kv ...interface{}) *logrus.Entry { kv = append(kv, nil) } - m := make(map[string]interface{}, len(kv)/2) + m := make(map[string]any, len(kv)/2) for i := 0; i < len(kv); i += 2 { key, ok := kv[i].(string) diff --git a/internal/logging/logrus_test.go b/internal/logging/logrus_test.go index 3e73193d1a..d74a3a0e01 100644 --- a/internal/logging/logrus_test.go +++ b/internal/logging/logrus_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -12,7 +12,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/pkg/errors" + "github.com/pkg/errors" //nolint:depguard // This is testing the logging of stack frames. "gotest.tools/v3/assert" ) diff --git a/internal/naming/annotations.go b/internal/naming/annotations.go index 2179a5f084..61a5438908 100644 --- a/internal/naming/annotations.go +++ b/internal/naming/annotations.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -32,6 +32,14 @@ const ( // (and therefore must be recreated) PGBackRestConfigHash = annotationPrefix + "pgbackrest-hash" + // PGBackRestCurrentConfig is an annotation used to indicate the name of the pgBackRest + // configuration associated with a specific Job as determined by either the current primary + // (if no dedicated repository host is enabled), or the dedicated repository host. This helps + // in detecting pgBackRest backup Jobs that no longer mount the proper pgBackRest + // configuration, e.g. because a failover has occurred, or because dedicated repo host has been + // enabled or disabled. + PGBackRestCurrentConfig = annotationPrefix + "pgbackrest-config" + // PGBackRestRestore is the annotation that is added to a PostgresCluster to initiate an in-place // restore. The value of the annotation will be a unique identifier for a restore Job (e.g. a // timestamp), which will be stored in the PostgresCluster status to properly track completion @@ -46,6 +54,10 @@ const ( // bind all addresses does not work in certain IPv6 environments. PGBackRestIPVersion = annotationPrefix + "pgbackrest-ip-version" + // PGBackRestCloudLogVolume is an annotation used to indicate which persistent volume claim + // should be mounted to cloud repo backup jobs so that the backup logs can be persisted. + PGBackRestCloudLogVolume = annotationPrefix + "pgbackrest-cloud-log-volume" + // PostgresExporterCollectorsAnnotation is an annotation used to allow users to control whether or // not postgres_exporter default metrics, settings, and collectors are enabled. The value "None" // disables all postgres_exporter defaults. Disabling the defaults may cause errors in dashboards. @@ -68,4 +80,9 @@ const ( // to a cluster without backups. As usual with the operator, we do not // touch cloud-based backups. AuthorizeBackupRemovalAnnotation = annotationPrefix + "authorizeBackupRemoval" + + // Used from Kubernetes v1.21+ to define a default container used when the + // `-c` flag is not passed. + // --https://kubernetes.io/docs/reference/labels-annotations-taints/#kubectl-kubernetes-io-default-container + DefaultContainerAnnotation = "kubectl.kubernetes.io/default-container" ) diff --git a/internal/naming/annotations_test.go b/internal/naming/annotations_test.go index 318dd5ab5c..9553e5e72a 100644 --- a/internal/naming/annotations_test.go +++ b/internal/naming/annotations_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -20,7 +20,9 @@ func TestAnnotationsValid(t *testing.T) { assert.Assert(t, nil == validation.IsQualifiedName(PGBackRestBackup)) assert.Assert(t, nil == validation.IsQualifiedName(PGBackRestBackupJobCompletion)) assert.Assert(t, nil == validation.IsQualifiedName(PGBackRestConfigHash)) + assert.Assert(t, nil == validation.IsQualifiedName(PGBackRestCurrentConfig)) assert.Assert(t, nil == validation.IsQualifiedName(PGBackRestIPVersion)) + assert.Assert(t, nil == validation.IsQualifiedName(PGBackRestCloudLogVolume)) assert.Assert(t, nil == validation.IsQualifiedName(PGBackRestRestore)) assert.Assert(t, nil == validation.IsQualifiedName(PostgresExporterCollectorsAnnotation)) } diff --git a/internal/naming/controllers.go b/internal/naming/controllers.go index 3d492e8a3a..b434b8dbc5 100644 --- a/internal/naming/controllers.go +++ b/internal/naming/controllers.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/naming/dns.go b/internal/naming/dns.go index d3351a5d70..37503c1aaf 100644 --- a/internal/naming/dns.go +++ b/internal/naming/dns.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -11,6 +11,8 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + + "github.com/crunchydata/postgres-operator/internal/tracing" ) // InstancePodDNSNames returns the possible DNS names for instance. The first @@ -68,7 +70,7 @@ func ServiceDNSNames(ctx context.Context, service *corev1.Service) []string { // KubernetesClusterDomain looks up the Kubernetes cluster domain name. func KubernetesClusterDomain(ctx context.Context) string { - ctx, span := tracer.Start(ctx, "kubernetes-domain-lookup") + ctx, span := tracing.Start(ctx, "kubernetes-domain-lookup") defer span.End() // Lookup an existing Service to determine its fully qualified domain name. @@ -77,11 +79,10 @@ func KubernetesClusterDomain(ctx context.Context) string { api := "kubernetes.default.svc" cname, err := net.DefaultResolver.LookupCNAME(ctx, api) - if err == nil { + if tracing.Check(span, err) { return strings.TrimPrefix(cname, api+".") } - span.RecordError(err) // The kubeadm default is "cluster.local" and is adequate when not running // in an actual Kubernetes cluster. return "cluster.local." diff --git a/internal/naming/dns_test.go b/internal/naming/dns_test.go index e7e2ea9dc6..a1ff726389 100644 --- a/internal/naming/dns_test.go +++ b/internal/naming/dns_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/naming/doc.go b/internal/naming/doc.go index 72cab8b0b0..c292436460 100644 --- a/internal/naming/doc.go +++ b/internal/naming/doc.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/naming/labels.go b/internal/naming/labels.go index f25993122b..209af0367b 100644 --- a/internal/naming/labels.go +++ b/internal/naming/labels.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -40,6 +40,10 @@ const ( // LabelMovePGWalDir is used to identify the Job that moves an existing pg_wal directory. LabelMovePGWalDir = labelPrefix + "move-pgwal-dir" + // LabelCollectorDiscovery is added to Pods running the OpenTelemetry "collector" + // container to support discovery by Prometheus + LabelCollectorDiscovery = labelPrefix + "crunchy-otel-collector" + // LabelPGBackRest is used to indicate that a resource is for pgBackRest LabelPGBackRest = labelPrefix + "pgbackrest" diff --git a/internal/naming/labels_test.go b/internal/naming/labels_test.go index b8a7779858..552e38ceb5 100644 --- a/internal/naming/labels_test.go +++ b/internal/naming/labels_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/naming/limitations.md b/internal/naming/limitations.md index ba607215f7..cc827cb0f2 100644 --- a/internal/naming/limitations.md +++ b/internal/naming/limitations.md @@ -1,5 +1,5 @@ @@ -83,6 +83,8 @@ name to 63 characters or less. The strategy for [generating Pod names](https://releases.k8s.io/v1.23.0/pkg/registry/core/pod/strategy.go#L62) truncates to 63 characters. The `.spec.hostname` field must be 63 characters or less. +[Container names are DNS labels](https://releases.k8s.io/v1.32.0/pkg/apis/core/validation/validation.go#L3563). + PodDisruptionBudget (PDB) [ReplicaSet names are DNS subdomains](https://releases.k8s.io/v1.23.0/pkg/apis/apps/validation/validation.go#L655). diff --git a/internal/naming/names.go b/internal/naming/names.go index 369591de91..f4ea8d2fd7 100644 --- a/internal/naming/names.go +++ b/internal/naming/names.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -18,6 +18,8 @@ import ( ) const ( + ContainerCollector = "collector" + // ContainerDatabase is the name of the container running PostgreSQL and // supporting tools: Patroni, pgBackRest, etc. ContainerDatabase = "database" @@ -131,6 +133,10 @@ const ( ) const ( + // PatroniPGDataLogPath is the Patroni default log path configuration used by the + // PostgreSQL instance. + PatroniPGDataLogPath = "/pgdata/patroni/log" + // PGBackRestRepoContainerName is the name assigned to the container used to run pgBackRest PGBackRestRepoContainerName = "pgbackrest" @@ -149,25 +155,17 @@ const ( // dedicated repo host, if configured. PGBackRestRepoLogPath = "/pgbackrest/%s/log" + // PGBouncerLogPath is the pgBouncer default log path configuration + PGBouncerLogPath = "/tmp" + + // PGbouncerFullLogPath is the full path to the pgbouncer log file + PGBouncerFullLogPath = PGBouncerLogPath + "/pgbouncer.log" + // suffix used with postgrescluster name for associated configmap. // for instance, if the cluster is named 'mycluster', the // configmap will be named 'mycluster-pgbackrest-config' cmNameSuffix = "%s-pgbackrest-config" - // suffix used with postgrescluster name for associated configmap. - // for instance, if the cluster is named 'mycluster', the - // configmap will be named 'mycluster-ssh-config' - // Deprecated: Repository hosts use mTLS for encryption, authentication, and authorization. - // TODO(tjmoore4): Once we no longer need this for cleanup purposes, this should be removed. - sshCMNameSuffix = "%s-ssh-config" - - // suffix used with postgrescluster name for associated secret. - // for instance, if the cluster is named 'mycluster', the - // secret will be named 'mycluster-ssh' - // Deprecated: Repository hosts use mTLS for encryption, authentication, and authorization. - // TODO(tjmoore4): Once we no longer need this for cleanup purposes, this should be removed. - sshSecretNameSuffix = "%s-ssh" - // RestoreConfigCopySuffix is the suffix used for ConfigMap or Secret configuration // resources needed when restoring from a PostgresCluster data source. If, for // example, a Secret is named 'mysecret' and is the first item in the configuration @@ -486,31 +484,20 @@ func PGBackRestRBAC(cluster *v1beta1.PostgresCluster) metav1.ObjectMeta { } } -// PGBackRestRepoVolume returns the ObjectMeta for a pgBackRest repository volume -func PGBackRestRepoVolume(cluster *v1beta1.PostgresCluster, - repoName string) metav1.ObjectMeta { +// RepoHostRBAC returns the ObjectMeta necessary to lookup the ServiceAccount for +// the pgBackRest Repo Host +func RepoHostRBAC(cluster *v1beta1.PostgresCluster) metav1.ObjectMeta { return metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-%s", cluster.GetName(), repoName), - Namespace: cluster.GetNamespace(), - } -} - -// PGBackRestSSHConfig returns the ObjectMeta for a pgBackRest SSHD ConfigMap -// Deprecated: Repository hosts use mTLS for encryption, authentication, and authorization. -// TODO(tjmoore4): Once we no longer need this for cleanup purposes, this should be removed. -func PGBackRestSSHConfig(cluster *v1beta1.PostgresCluster) metav1.ObjectMeta { - return metav1.ObjectMeta{ - Name: fmt.Sprintf(sshCMNameSuffix, cluster.GetName()), - Namespace: cluster.GetNamespace(), + Namespace: cluster.Namespace, + Name: cluster.Name + "-repohost", } } -// PGBackRestSSHSecret returns the ObjectMeta for a pgBackRest SSHD Secret -// Deprecated: Repository hosts use mTLS for encryption, authentication, and authorization. -// TODO(tjmoore4): Once we no longer need this for cleanup purposes, this should be removed. -func PGBackRestSSHSecret(cluster *v1beta1.PostgresCluster) metav1.ObjectMeta { +// PGBackRestRepoVolume returns the ObjectMeta for a pgBackRest repository volume +func PGBackRestRepoVolume(cluster *v1beta1.PostgresCluster, + repoName string) metav1.ObjectMeta { return metav1.ObjectMeta{ - Name: fmt.Sprintf(sshSecretNameSuffix, cluster.GetName()), + Name: fmt.Sprintf("%s-%s", cluster.GetName(), repoName), Namespace: cluster.GetNamespace(), } } diff --git a/internal/naming/names_test.go b/internal/naming/names_test.go index 27835c3e5d..16a175a617 100644 --- a/internal/naming/names_test.go +++ b/internal/naming/names_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -88,7 +88,6 @@ func TestClusterNamesUniqueAndValid(t *testing.T) { {"PatroniLeaderConfigMap", PatroniLeaderConfigMap(cluster)}, {"PatroniTrigger", PatroniTrigger(cluster)}, {"PGBackRestConfig", PGBackRestConfig(cluster)}, - {"PGBackRestSSHConfig", PGBackRestSSHConfig(cluster)}, }) }) @@ -141,7 +140,6 @@ func TestClusterNamesUniqueAndValid(t *testing.T) { {"DeprecatedPostgresUserSecret", DeprecatedPostgresUserSecret(cluster)}, {"PostgresTLSSecret", PostgresTLSSecret(cluster)}, {"ReplicationClientCertSecret", ReplicationClientCertSecret(cluster)}, - {"PGBackRestSSHSecret", PGBackRestSSHSecret(cluster)}, {"MonitoringUserSecret", MonitoringUserSecret(cluster)}, }) diff --git a/internal/naming/selectors.go b/internal/naming/selectors.go index 94dbc3a9fa..c51f2d0262 100644 --- a/internal/naming/selectors.go +++ b/internal/naming/selectors.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -152,6 +152,13 @@ func ClusterPostgresUsers(cluster string) metav1.LabelSelector { } } +// ClusterPrimary selects things for the Primary PostgreSQL instance. +func ClusterPrimary(cluster string) metav1.LabelSelector { + s := ClusterInstances(cluster) + s.MatchLabels[LabelRole] = RolePatroniLeader + return s +} + // CrunchyBridgeClusterPostgresRoles selects things labeled for CrunchyBridgeCluster // PostgreSQL roles in cluster. func CrunchyBridgeClusterPostgresRoles(clusterName string) metav1.LabelSelector { diff --git a/internal/naming/selectors_test.go b/internal/naming/selectors_test.go index 1f5f42ad96..c8617bcb78 100644 --- a/internal/naming/selectors_test.go +++ b/internal/naming/selectors_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -148,6 +148,16 @@ func TestClusterPostgresUsers(t *testing.T) { assert.ErrorContains(t, err, "Invalid") } +func TestClusterPrimary(t *testing.T) { + s, err := AsSelector(ClusterPrimary("something")) + assert.NilError(t, err) + assert.DeepEqual(t, s.String(), strings.Join([]string{ + "postgres-operator.crunchydata.com/cluster=something", + "postgres-operator.crunchydata.com/instance", + "postgres-operator.crunchydata.com/role=master", + }, ",")) +} + func TestCrunchyBridgeClusterPostgresRoles(t *testing.T) { s, err := AsSelector(CrunchyBridgeClusterPostgresRoles("something")) assert.NilError(t, err) diff --git a/internal/naming/telemetry.go b/internal/naming/telemetry.go deleted file mode 100644 index 5825d6299f..0000000000 --- a/internal/naming/telemetry.go +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. -// -// SPDX-License-Identifier: Apache-2.0 - -package naming - -import "go.opentelemetry.io/otel" - -var tracer = otel.Tracer("github.com/crunchydata/postgres-operator/naming") diff --git a/internal/patroni/api.go b/internal/patroni/api.go index 679da5f4af..502a354d43 100644 --- a/internal/patroni/api.go +++ b/internal/patroni/api.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -45,7 +45,7 @@ func (exec Executor) ChangePrimaryAndWait( err := exec(ctx, nil, &stdout, &stderr, "patronictl", "switchover", "--scheduled=now", "--force", - "--master="+current, "--candidate="+next) + "--primary="+current, "--candidate="+next) log := logging.FromContext(ctx) log.V(1).Info("changed primary", @@ -65,7 +65,7 @@ func (exec Executor) ChangePrimaryAndWait( // "patronictl". It returns true when an election completes successfully. It // waits up to two "loop_wait" or until an error occurs. When Patroni is paused, // next cannot be blank. Similar to the "POST /switchover" REST endpoint. -// The "patronictl switchover" variant does not require the current master to be passed +// The "patronictl switchover" variant does not require the current primary to be passed // as a flag. func (exec Executor) SwitchoverAndWait( ctx context.Context, target string, @@ -96,7 +96,7 @@ func (exec Executor) SwitchoverAndWait( // "patronictl". It returns true when an election completes successfully. It // waits up to two "loop_wait" or until an error occurs. When Patroni is paused, // next cannot be blank. Similar to the "POST /switchover" REST endpoint. -// The "patronictl failover" variant does not require the current master to be passed +// The "patronictl failover" variant does not require the current primary to be passed // as a flag. func (exec Executor) FailoverAndWait( ctx context.Context, target string, diff --git a/internal/patroni/api_test.go b/internal/patroni/api_test.go index 1603d2fc75..e852637bba 100644 --- a/internal/patroni/api_test.go +++ b/internal/patroni/api_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -36,7 +36,7 @@ func TestExecutorChangePrimaryAndWait(t *testing.T) { ) error { called = true assert.DeepEqual(t, command, strings.Fields( - `patronictl switchover --scheduled=now --force --master=old --candidate=new`, + `patronictl switchover --scheduled=now --force --primary=old --candidate=new`, )) assert.Assert(t, stdin == nil, "expected no stdin, got %T", stdin) assert.Assert(t, stderr != nil, "should capture stderr") @@ -243,7 +243,7 @@ func TestExecutorGetTimeline(t *testing.T) { tl, actual := Executor(func( _ context.Context, stdin io.Reader, stdout, stderr io.Writer, command ...string, ) error { - stderr.Write([]byte(`no luck`)) + _, _ = stderr.Write([]byte(`no luck`)) return nil }).GetTimeline(context.Background()) @@ -255,7 +255,7 @@ func TestExecutorGetTimeline(t *testing.T) { tl, actual := Executor(func( _ context.Context, stdin io.Reader, stdout, stderr io.Writer, command ...string, ) error { - stdout.Write([]byte(`no luck`)) + _, _ = stdout.Write([]byte(`no luck`)) return nil }).GetTimeline(context.Background()) @@ -267,7 +267,7 @@ func TestExecutorGetTimeline(t *testing.T) { tl, actual := Executor(func( _ context.Context, stdin io.Reader, stdout, stderr io.Writer, command ...string, ) error { - stdout.Write([]byte(`[{"Cluster": "hippo-ha", "Member": "hippo-instance1-ltcf-0", "Host": "hippo-instance1-ltcf-0.hippo-pods", "Role": "Replica", "State": "running", "TL": 4, "Lag in MB": 0}]`)) + _, _ = stdout.Write([]byte(`[{"Cluster": "hippo-ha", "Member": "hippo-instance1-ltcf-0", "Host": "hippo-instance1-ltcf-0.hippo-pods", "Role": "Replica", "State": "running", "TL": 4, "Lag in MB": 0}]`)) return nil }).GetTimeline(context.Background()) @@ -279,7 +279,7 @@ func TestExecutorGetTimeline(t *testing.T) { tl, actual := Executor(func( _ context.Context, stdin io.Reader, stdout, stderr io.Writer, command ...string, ) error { - stdout.Write([]byte(`[{"Cluster": "hippo-ha", "Member": "hippo-instance1-67mc-0", "Host": "hippo-instance1-67mc-0.hippo-pods", "Role": "Leader", "State": "running", "TL": 4}, {"Cluster": "hippo-ha", "Member": "hippo-instance1-ltcf-0", "Host": "hippo-instance1-ltcf-0.hippo-pods", "Role": "Replica", "State": "running", "TL": 4, "Lag in MB": 0}]`)) + _, _ = stdout.Write([]byte(`[{"Cluster": "hippo-ha", "Member": "hippo-instance1-67mc-0", "Host": "hippo-instance1-67mc-0.hippo-pods", "Role": "Leader", "State": "running", "TL": 4}, {"Cluster": "hippo-ha", "Member": "hippo-instance1-ltcf-0", "Host": "hippo-instance1-ltcf-0.hippo-pods", "Role": "Replica", "State": "running", "TL": 4, "Lag in MB": 0}]`)) return nil }).GetTimeline(context.Background()) diff --git a/internal/patroni/certificates.go b/internal/patroni/certificates.go index 9aa1525769..45db4fa2f7 100644 --- a/internal/patroni/certificates.go +++ b/internal/patroni/certificates.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/patroni/certificates.md b/internal/patroni/certificates.md index f58786ce20..14739030ef 100644 --- a/internal/patroni/certificates.md +++ b/internal/patroni/certificates.md @@ -1,5 +1,5 @@ diff --git a/internal/patroni/certificates_test.go b/internal/patroni/certificates_test.go index 3073f2247f..5c91f88691 100644 --- a/internal/patroni/certificates_test.go +++ b/internal/patroni/certificates_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/patroni/config.go b/internal/patroni/config.go index b4d7e54f68..7815fc8c8a 100644 --- a/internal/patroni/config.go +++ b/internal/patroni/config.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,6 +6,7 @@ package patroni import ( "fmt" + "maps" "path" "strings" @@ -13,8 +14,10 @@ import ( "sigs.k8s.io/yaml" "github.com/crunchydata/postgres-operator/internal/config" + "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/postgres" + "github.com/crunchydata/postgres-operator/internal/shell" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -34,16 +37,10 @@ const ( "# Your changes will not be saved.\n" ) -// quoteShellWord ensures that s is interpreted by a shell as single word. -func quoteShellWord(s string) string { - // https://www.gnu.org/software/bash/manual/html_node/Quoting.html - return `'` + strings.ReplaceAll(s, `'`, `'"'"'`) + `'` -} - // clusterYAML returns Patroni settings that apply to the entire cluster. func clusterYAML( cluster *v1beta1.PostgresCluster, - pgHBAs postgres.HBAs, pgParameters postgres.Parameters, + pgHBAs *postgres.OrderedHBAs, parameters *postgres.ParameterSet, patroniLogStorageLimit int64, ) (string, error) { root := map[string]any{ // The cluster identifier. This value cannot change during the cluster's @@ -60,6 +57,11 @@ func clusterYAML( "role_label": naming.LabelRole, "scope_label": naming.LabelPatroni, "use_endpoints": true, + // To support transitioning to Patroni v4, set the value to 'master'. + // In a future release, this can be removed in favor of the default. + // Do this for leaders in both primary and standby clusters. + "leader_label_value": naming.RolePatroniLeader, + "standby_leader_label_value": naming.RolePatroniLeader, // In addition to "scope_label" above, Patroni will add the following to // every object it creates. It will also use these as filters when doing @@ -152,17 +154,48 @@ func clusterYAML( }, } + // If a Patroni log file size is configured (the user set it in the + // spec or the OpenTelemetryLogs feature gate is enabled), we need to + // configure volume file storage + if patroniLogStorageLimit != 0 { + + logLevel := initialize.Pointer("INFO") + if cluster.Spec.Patroni != nil && cluster.Spec.Patroni.Logging != nil && + cluster.Spec.Patroni.Logging.Level != nil { + logLevel = cluster.Spec.Patroni.Logging.Level + } + + // Configure the Patroni log settings + // - https://patroni.readthedocs.io/en/latest/yaml_configuration.html#log + root["log"] = map[string]any{ + + "dir": naming.PatroniPGDataLogPath, + "type": "json", + + // defaults to "INFO" + "level": logLevel, + + // Setting group read permissions so that the OTel filelog receiver can + // read the log files. + // NOTE: This log configuration setting is only available in Patroni v4 + "mode": "0660", + + // There will only be two log files. Cannot set to 1 or the logs won't rotate. + // - https://github.com/python/cpython/blob/3.11/Lib/logging/handlers.py#L134 + "file_num": 1, + + // Since there are two log files, ensure the total space used is under + // the configured limit. + "file_size": patroniLogStorageLimit / 2, + } + } + if !ClusterBootstrapped(cluster) { // Patroni has not yet bootstrapped. Populate the "bootstrap.dcs" field to // facilitate it. When Patroni is already bootstrapped, this field is ignored. - var configuration map[string]any - if cluster.Spec.Patroni != nil { - configuration = cluster.Spec.Patroni.DynamicConfiguration - } - root["bootstrap"] = map[string]any{ - "dcs": DynamicConfiguration(cluster, configuration, pgHBAs, pgParameters), + "dcs": DynamicConfiguration(&cluster.Spec, pgHBAs, parameters), // Missing here is "users" which runs *after* "post_bootstrap". It is // not possible to use roles created by the former in the latter. @@ -177,20 +210,19 @@ func clusterYAML( // DynamicConfiguration combines configuration with some PostgreSQL settings // and returns a value that can be marshaled to JSON. func DynamicConfiguration( - cluster *v1beta1.PostgresCluster, - configuration map[string]any, - pgHBAs postgres.HBAs, pgParameters postgres.Parameters, + spec *v1beta1.PostgresClusterSpec, + pgHBAs *postgres.OrderedHBAs, parameters *postgres.ParameterSet, ) map[string]any { // Copy the entire configuration before making any changes. - root := make(map[string]any, len(configuration)) - for k, v := range configuration { - root[k] = v + root := make(map[string]any) + if spec.Patroni != nil && spec.Patroni.DynamicConfiguration != nil { + root = spec.Patroni.DynamicConfiguration.DeepCopy() } - root["ttl"] = *cluster.Spec.Patroni.LeaderLeaseDurationSeconds - root["loop_wait"] = *cluster.Spec.Patroni.SyncPeriodSeconds + // NOTE: These are always populated due to [v1beta1.PatroniSpec.Default] + root["ttl"] = *spec.Patroni.LeaderLeaseDurationSeconds + root["loop_wait"] = *spec.Patroni.SyncPeriodSeconds - // Copy the "postgresql" section before making any changes. postgresql := map[string]any{ // TODO(cbandy): explain this. requires an archive, perhaps. "use_slots": false, @@ -198,76 +230,23 @@ func DynamicConfiguration( // When TDE is configured, override the pg_rewind binary name to point // to the wrapper script. - if config.FetchKeyCommand(&cluster.Spec) != "" { + if config.FetchKeyCommand(spec) != "" { postgresql["bin_name"] = map[string]any{ "pg_rewind": "/tmp/pg_rewind_tde.sh", } } + // Copy the "postgresql" section over the above defaults. if section, ok := root["postgresql"].(map[string]any); ok { - for k, v := range section { - postgresql[k] = v - } + maps.Copy(postgresql, section) } - root["postgresql"] = postgresql - - // Copy the "postgresql.parameters" section over any defaults. - parameters := make(map[string]any) - if pgParameters.Default != nil { - for k, v := range pgParameters.Default.AsMap() { - parameters[k] = v - } + if m := parameters.AsMap(); m != nil { + postgresql["parameters"] = m } - if section, ok := postgresql["parameters"].(map[string]any); ok { - for k, v := range section { - parameters[k] = v - } - } - // Override the above with mandatory parameters. - if pgParameters.Mandatory != nil { - for k, v := range pgParameters.Mandatory.AsMap() { - - // This parameter is a comma-separated list. Rather than overwrite the - // user-defined value, we want to combine it with the mandatory one. - // Some libraries belong at specific positions in the list, so figure - // that out as well. - if k == "shared_preload_libraries" { - // Load mandatory libraries ahead of user-defined libraries. - if s, ok := parameters[k].(string); ok && len(s) > 0 { - v = v + "," + s - } - // Load "citus" ahead of any other libraries. - // - https://github.com/citusdata/citus/blob/v12.0.0/src/backend/distributed/shared_library_init.c#L417-L419 - if strings.Contains(v, "citus") { - v = "citus," + v - } - } - - parameters[k] = v - } + if pgHBAs != nil { + postgresql["pg_hba"] = pgHBAs.AsStrings() } - postgresql["parameters"] = parameters - - // Copy the "postgresql.pg_hba" section after any mandatory values. - hba := make([]string, 0, len(pgHBAs.Mandatory)) - for i := range pgHBAs.Mandatory { - hba = append(hba, pgHBAs.Mandatory[i].String()) - } - if section, ok := postgresql["pg_hba"].([]any); ok { - for i := range section { - // any pg_hba values that are not strings will be skipped - if value, ok := section[i].(string); ok { - hba = append(hba, value) - } - } - } - // When the section is missing or empty, include the recommended defaults. - if len(hba) == len(pgHBAs.Mandatory) { - for i := range pgHBAs.Default { - hba = append(hba, pgHBAs.Default[i].String()) - } - } - postgresql["pg_hba"] = hba + root["postgresql"] = postgresql // Enabling `pg_rewind` allows a former primary to automatically rejoin the // cluster even if it has commits that were not sent to a replica. In other @@ -277,15 +256,12 @@ func DynamicConfiguration( // Recent versions of `pg_rewind` can run with limited permissions granted // by Patroni to the user defined in "postgresql.authentication.rewind". // PostgreSQL v10 and earlier require superuser access over the network. - postgresql["use_pg_rewind"] = cluster.Spec.PostgresVersion > 10 - - if cluster.Spec.Standby != nil && cluster.Spec.Standby.Enabled { - // Copy the "standby_cluster" section before making any changes. - standby := make(map[string]any) - if section, ok := root["standby_cluster"].(map[string]any); ok { - for k, v := range section { - standby[k] = v - } + postgresql["use_pg_rewind"] = spec.PostgresVersion > 10 + + if spec.Standby != nil && spec.Standby.Enabled { + standby, _ := root["standby_cluster"].(map[string]any) + if standby == nil { + standby = make(map[string]any) } // Unset any previous value for restore_command - we will set it later if needed @@ -293,23 +269,23 @@ func DynamicConfiguration( // Populate replica creation methods based on options provided in the standby spec: methods := []string{} - if cluster.Spec.Standby.Host != "" { - standby["host"] = cluster.Spec.Standby.Host - if cluster.Spec.Standby.Port != nil { - standby["port"] = *cluster.Spec.Standby.Port + if spec.Standby.Host != "" { + standby["host"] = spec.Standby.Host + if spec.Standby.Port != nil { + standby["port"] = *spec.Standby.Port } methods = append([]string{basebackupCreateReplicaMethod}, methods...) } - if cluster.Spec.Standby.RepoName != "" { + if spec.Standby.RepoName != "" { // Append pgbackrest as the first choice when creating the standby methods = append([]string{pgBackRestCreateReplicaMethod}, methods...) // Populate the standby leader by shipping logs through pgBackRest. // This also overrides the "restore_command" used by standby replicas. // - https://www.postgresql.org/docs/current/warm-standby.html - standby["restore_command"] = pgParameters.Mandatory.Value("restore_command") + standby["restore_command"] = parameters.Value("restore_command") } standby["create_replica_methods"] = methods @@ -488,7 +464,7 @@ func instanceYAML( // created. That value should be injected using the downward API and the // PATRONI_KUBERNETES_POD_IP environment variable. - // Missing here is "ports" which is is connascent with "postgresql.connect_address". + // Missing here is "ports" which is connascent with "postgresql.connect_address". // See the PATRONI_KUBERNETES_PORTS env variable. }, @@ -542,31 +518,12 @@ func instanceYAML( if command := pgbackrestReplicaCreateCommand; len(command) > 0 { // Regardless of the "keep_data" setting below, Patroni deletes the - // data directory when all methods fail. pgBackRest will not restore - // when the data directory is missing, so create it before running the - // command. PostgreSQL requires that the directory is writable by only - // itself. - // - https://github.com/zalando/patroni/blob/v2.0.2/patroni/ha.py#L249 - // - https://github.com/pgbackrest/pgbackrest/issues/1445 - // - https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/backend/utils/init/miscinit.c;hb=REL_13_0#l319 - // - // NOTE(cbandy): The "PATRONI_POSTGRESQL_DATA_DIR" environment variable - // is defined in this package, but it is removed by Patroni at runtime. - command = append([]string{ - "bash", "-ceu", "--", - `install --directory --mode=0700 "${PGDATA?}" && exec "$@"`, - "-", - }, command...) - - quoted := make([]string, len(command)) - for i := range command { - quoted[i] = quoteShellWord(command[i]) - } + // data directory when all methods fail. postgresql[pgBackRestCreateReplicaMethod] = map[string]any{ - "command": strings.Join(quoted, " "), - "keep_data": true, - "no_master": true, - "no_params": true, + "command": strings.Join(shell.QuoteWords(command...), " "), + "keep_data": true, // Use the data directory from a prior method. + "no_leader": true, // Works without a replication connection. + "no_params": true, // Patroni should not add "--scope", "--role", etc. } methods = append([]string{pgBackRestCreateReplicaMethod}, methods...) } diff --git a/internal/patroni/config.md b/internal/patroni/config.md index 18d28d8a4e..ffd091601c 100644 --- a/internal/patroni/config.md +++ b/internal/patroni/config.md @@ -1,5 +1,5 @@ @@ -214,7 +214,7 @@ acquiring the leader lock, the Patroni leader: | - | postgresql.basebackup | Yes | mutable | either | List of arguments to pass to pg_basebackup when using the `basebackup` replica method. | - | postgresql.{method}.command | Yes¹ | mutable | either | Command to execute for this replica method. | - | postgresql.{method}.keep_data | Yes¹ | mutable | either | Whether or not Patroni should empty the data directory before. (default: false) -| - | postgresql.{method}.no_master | Yes¹ | mutable | either | Whether or not Patroni can call this method when no instances are running. (default: false) +| - | postgresql.{method}.no_leader | Yes¹ | mutable | either | Whether or not Patroni can call this method when no instances are running. (default: false) | - | postgresql.{method}.no_params | Yes¹ | mutable | either | Whether or not Patroni should pass extra arguments to the command. (default: false) || |||||| https://github.com/zalando/patroni/blob/v2.0.1/docs/replica_bootstrap.rst#bootstrap diff --git a/internal/patroni/config_test.go b/internal/patroni/config_test.go index a45568df8b..d5aef835e7 100644 --- a/internal/patroni/config_test.go +++ b/internal/patroni/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,10 +13,10 @@ import ( "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" - "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/postgres" "github.com/crunchydata/postgres-operator/internal/testing/cmp" "github.com/crunchydata/postgres-operator/internal/testing/require" @@ -32,7 +32,7 @@ func TestClusterYAML(t *testing.T) { cluster.Namespace = "some-namespace" cluster.Name = "cluster-name" - data, err := clusterYAML(cluster, postgres.HBAs{}, postgres.Parameters{}) + data, err := clusterYAML(cluster, nil, nil, 0) assert.NilError(t, err) assert.Equal(t, data, strings.TrimSpace(` # Generated by postgres-operator. DO NOT EDIT. @@ -41,8 +41,6 @@ bootstrap: dcs: loop_wait: 10 postgresql: - parameters: {} - pg_hba: [] use_pg_rewind: false use_slots: false ttl: 30 @@ -54,9 +52,11 @@ ctl: kubernetes: labels: postgres-operator.crunchydata.com/cluster: cluster-name + leader_label_value: master namespace: some-namespace role_label: postgres-operator.crunchydata.com/role scope_label: postgres-operator.crunchydata.com/patroni + standby_leader_label_value: master use_endpoints: true postgresql: authentication: @@ -90,7 +90,7 @@ watchdog: cluster.Name = "cluster-name" cluster.Spec.PostgresVersion = 14 - data, err := clusterYAML(cluster, postgres.HBAs{}, postgres.Parameters{}) + data, err := clusterYAML(cluster, nil, nil, 0) assert.NilError(t, err) assert.Equal(t, data, strings.TrimSpace(` # Generated by postgres-operator. DO NOT EDIT. @@ -99,8 +99,6 @@ bootstrap: dcs: loop_wait: 10 postgresql: - parameters: {} - pg_hba: [] use_pg_rewind: true use_slots: false ttl: 30 @@ -112,9 +110,11 @@ ctl: kubernetes: labels: postgres-operator.crunchydata.com/cluster: cluster-name + leader_label_value: master namespace: some-namespace role_label: postgres-operator.crunchydata.com/role scope_label: postgres-operator.crunchydata.com/patroni + standby_leader_label_value: master use_endpoints: true postgresql: authentication: @@ -136,6 +136,80 @@ restapi: keyfile: null verify_client: optional scope: cluster-name-ha +watchdog: + mode: "off" + `)+"\n") + }) + + t.Run("PatroniLogSizeConfigured", func(t *testing.T) { + cluster := new(v1beta1.PostgresCluster) + cluster.Default() + cluster.Namespace = "some-namespace" + cluster.Name = "cluster-name" + cluster.Spec.PostgresVersion = 14 + + fileSize, err := resource.ParseQuantity("1k") + assert.NilError(t, err) + + logLevel := "DEBUG" + cluster.Spec.Patroni.Logging = &v1beta1.PatroniLogConfig{ + StorageLimit: &fileSize, + Level: &logLevel, + } + + data, err := clusterYAML(cluster, nil, nil, 1000) + assert.NilError(t, err) + assert.Equal(t, data, strings.TrimSpace(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. +bootstrap: + dcs: + loop_wait: 10 + postgresql: + use_pg_rewind: true + use_slots: false + ttl: 30 +ctl: + cacert: /etc/patroni/~postgres-operator/patroni.ca-roots + certfile: /etc/patroni/~postgres-operator/patroni.crt+key + insecure: false + keyfile: null +kubernetes: + labels: + postgres-operator.crunchydata.com/cluster: cluster-name + leader_label_value: master + namespace: some-namespace + role_label: postgres-operator.crunchydata.com/role + scope_label: postgres-operator.crunchydata.com/patroni + standby_leader_label_value: master + use_endpoints: true +log: + dir: /pgdata/patroni/log + file_num: 1 + file_size: 500 + level: DEBUG + mode: "0660" + type: json +postgresql: + authentication: + replication: + sslcert: /tmp/replication/tls.crt + sslkey: /tmp/replication/tls.key + sslmode: verify-ca + sslrootcert: /tmp/replication/ca.crt + username: _crunchyrepl + rewind: + sslcert: /tmp/replication/tls.crt + sslkey: /tmp/replication/tls.key + sslmode: verify-ca + sslrootcert: /tmp/replication/ca.crt + username: _crunchyrepl +restapi: + cafile: /etc/patroni/~postgres-operator/patroni.ca-roots + certfile: /etc/patroni/~postgres-operator/patroni.crt+key + keyfile: null + verify_client: optional +scope: cluster-name-ha watchdog: mode: "off" `)+"\n") @@ -153,12 +227,17 @@ func TestDynamicConfiguration(t *testing.T) { return out } + rules := func(in ...string) *postgres.OrderedHBAs { + out := new(postgres.OrderedHBAs) + out.AppendUnstructured(in...) + return out + } + for _, tt := range []struct { name string - cluster *v1beta1.PostgresCluster - input map[string]any - hbas postgres.HBAs - params postgres.Parameters + spec string + hbas *postgres.OrderedHBAs + params *postgres.ParameterSet expected map[string]any }{ { @@ -167,8 +246,6 @@ func TestDynamicConfiguration(t *testing.T) { "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, @@ -176,16 +253,18 @@ func TestDynamicConfiguration(t *testing.T) { }, { name: "top-level passes through", - input: map[string]any{ - "retry_timeout": 5, - }, + spec: `{ + patroni: { + dynamicConfiguration: { + retry_timeout: 5, + }, + }, + }`, expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), - "retry_timeout": 5, + "retry_timeout": int64(5), "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, @@ -193,24 +272,20 @@ func TestDynamicConfiguration(t *testing.T) { }, { name: "top-level: spec overrides input", - cluster: &v1beta1.PostgresCluster{ - Spec: v1beta1.PostgresClusterSpec{ - Patroni: &v1beta1.PatroniSpec{ - LeaderLeaseDurationSeconds: initialize.Int32(99), - SyncPeriodSeconds: initialize.Int32(8), + spec: `{ + patroni: { + leaderLeaseDurationSeconds: 99, + syncPeriodSeconds: 8, + dynamicConfiguration: { + loop_wait: 3, + ttl: nope, }, }, - }, - input: map[string]any{ - "loop_wait": 3, - "ttl": "nope", - }, + }`, expected: map[string]any{ "loop_wait": int32(8), "ttl": int32(99), "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, @@ -218,339 +293,92 @@ func TestDynamicConfiguration(t *testing.T) { }, { name: "postgresql: wrong-type is ignored", - input: map[string]any{ - "postgresql": true, - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{}, - "use_pg_rewind": true, - "use_slots": false, - }, - }, - }, - { - name: "postgresql: defaults and overrides", - input: map[string]any{ - "postgresql": map[string]any{ - "use_pg_rewind": "overridden", - "use_slots": "input", - }, - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{}, - "use_pg_rewind": true, - "use_slots": "input", - }, - }, - }, - { - name: "postgresql.parameters: wrong-type is ignored", - input: map[string]any{ - "postgresql": map[string]any{ - "parameters": true, + spec: `{ + patroni: { + dynamicConfiguration: { + postgresql: true, + }, }, - }, + }`, expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, }, }, { - name: "postgresql.parameters: input passes through", - input: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "something": "str", - "another": 5, + name: "postgresql: defaults and overrides", + spec: `{ + patroni: { + dynamicConfiguration: { + postgresql: { + use_pg_rewind: overidden, + use_slots: input, + }, }, }, - }, + }`, expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{ - "something": "str", - "another": 5, - }, - "pg_hba": []string{}, "use_pg_rewind": true, - "use_slots": false, + "use_slots": "input", }, }, }, { - name: "postgresql.parameters: input overrides default", - input: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "something": "str", - "another": 5, + name: "Postgres parameters pass through", + spec: `{ + patroni: { + dynamicConfiguration: { + postgresql: { + parameters: { + calculated: elsewhere, + }, + }, }, }, - }, - params: postgres.Parameters{ - Default: parameters(map[string]string{ - "something": "overridden", - "unrelated": "default", - }), - }, + }`, + params: parameters(map[string]string{ + "something": "str", + "another": "5", + "unrelated": "default", + }), expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{ + "parameters": map[string]string{ "something": "str", - "another": 5, + "another": "5", "unrelated": "default", }, - "pg_hba": []string{}, - "use_pg_rewind": true, - "use_slots": false, - }, - }, - }, - { - name: "postgresql.parameters: mandatory overrides input", - input: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "something": "str", - "another": 5, - }, - }, - }, - params: postgres.Parameters{ - Mandatory: parameters(map[string]string{ - "something": "overrides", - "unrelated": "setting", - }), - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{ - "something": "overrides", - "another": 5, - "unrelated": "setting", - }, - "pg_hba": []string{}, - "use_pg_rewind": true, - "use_slots": false, - }, - }, - }, - { - name: "postgresql.parameters: mandatory shared_preload_libraries", - input: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "shared_preload_libraries": "given", - }, - }, - }, - params: postgres.Parameters{ - Mandatory: parameters(map[string]string{ - "shared_preload_libraries": "mandatory", - }), - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{ - "shared_preload_libraries": "mandatory,given", - }, - "pg_hba": []string{}, - "use_pg_rewind": true, - "use_slots": false, - }, - }, - }, - { - name: "postgresql.parameters: mandatory shared_preload_libraries wrong-type is ignored", - input: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "shared_preload_libraries": 1, - }, - }, - }, - params: postgres.Parameters{ - Mandatory: parameters(map[string]string{ - "shared_preload_libraries": "mandatory", - }), - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{ - "shared_preload_libraries": "mandatory", - }, - "pg_hba": []string{}, - "use_pg_rewind": true, - "use_slots": false, - }, - }, - }, - { - name: "postgresql.parameters: shared_preload_libraries order", - input: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "shared_preload_libraries": "given, citus, more", - }, - }, - }, - params: postgres.Parameters{ - Mandatory: parameters(map[string]string{ - "shared_preload_libraries": "mandatory", - }), - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{ - "shared_preload_libraries": "citus,mandatory,given, citus, more", - }, - "pg_hba": []string{}, - "use_pg_rewind": true, - "use_slots": false, - }, - }, - }, - { - name: "postgresql.pg_hba: wrong-type is ignored", - input: map[string]any{ - "postgresql": map[string]any{ - "pg_hba": true, - }, - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, }, }, { - name: "postgresql.pg_hba: default when no input", - input: map[string]any{ - "postgresql": map[string]any{ - "pg_hba": nil, - }, - }, - hbas: postgres.HBAs{ - Default: []postgres.HostBasedAuthentication{ - *postgres.NewHBA().Local().Method("peer"), - }, - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{ - "local all all peer", - }, - "use_pg_rewind": true, - "use_slots": false, - }, - }, - }, - { - name: "postgresql.pg_hba: no default when input", - input: map[string]any{ - "postgresql": map[string]any{ - "pg_hba": []any{"custom"}, - }, - }, - hbas: postgres.HBAs{ - Default: []postgres.HostBasedAuthentication{ - *postgres.NewHBA().Local().Method("peer"), - }, - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{ - "custom", - }, - "use_pg_rewind": true, - "use_slots": false, - }, - }, - }, - { - name: "postgresql.pg_hba: mandatory before others", - input: map[string]any{ - "postgresql": map[string]any{ - "pg_hba": []any{"custom"}, - }, - }, - hbas: postgres.HBAs{ - Mandatory: []postgres.HostBasedAuthentication{ - *postgres.NewHBA().Local().Method("peer"), - }, - }, - expected: map[string]any{ - "loop_wait": int32(10), - "ttl": int32(30), - "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{ - "local all all peer", - "custom", + name: "HBA pass through", + spec: `{ + patroni: { + dynamicConfiguration: { + postgresql: { + pg_hba: [calculated, elsewhere], + }, }, - "use_pg_rewind": true, - "use_slots": false, }, - }, - }, - { - name: "postgresql.pg_hba: ignore non-string types", - input: map[string]any{ - "postgresql": map[string]any{ - "pg_hba": []any{1, true, "custom", map[string]string{}, []string{}}, - }, - }, - hbas: postgres.HBAs{ - Mandatory: []postgres.HostBasedAuthentication{ - *postgres.NewHBA().Local().Method("peer"), - }, - }, + }`, + hbas: rules("function args"), expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{ - "local all all peer", - "custom", - }, + "pg_hba": []string{"function args"}, "use_pg_rewind": true, "use_slots": false, }, @@ -558,17 +386,19 @@ func TestDynamicConfiguration(t *testing.T) { }, { name: "standby_cluster: input passes through", - input: map[string]any{ - "standby_cluster": map[string]any{ - "primary_slot_name": "str", + spec: `{ + patroni: { + dynamicConfiguration: { + standby_cluster: { + primary_slot_name: str, + }, + }, }, - }, + }`, expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{}, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, @@ -579,33 +409,30 @@ func TestDynamicConfiguration(t *testing.T) { }, { name: "standby_cluster: repo only", - cluster: &v1beta1.PostgresCluster{ - Spec: v1beta1.PostgresClusterSpec{ - Standby: &v1beta1.PostgresStandbySpec{ - Enabled: true, - RepoName: "repo", + spec: `{ + standby: { + enabled: true, + repoName: repo, + }, + patroni: { + dynamicConfiguration: { + standby_cluster: { + restore_command: overridden, + unrelated: input, + }, }, }, - }, - input: map[string]any{ - "standby_cluster": map[string]any{ - "restore_command": "overridden", - "unrelated": "input", - }, - }, - params: postgres.Parameters{ - Mandatory: parameters(map[string]string{ - "restore_command": "mandatory", - }), - }, + }`, + params: parameters(map[string]string{ + "restore_command": "mandatory", + }), expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{ + "parameters": map[string]string{ "restore_command": "mandatory", }, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, @@ -618,36 +445,33 @@ func TestDynamicConfiguration(t *testing.T) { }, { name: "standby_cluster: basebackup for streaming", - cluster: &v1beta1.PostgresCluster{ - Spec: v1beta1.PostgresClusterSpec{ - Standby: &v1beta1.PostgresStandbySpec{ - Enabled: true, - Host: "0.0.0.0", - Port: initialize.Int32(5432), + spec: `{ + standby: { + enabled: true, + host: 0.0.0.0, + port: 5432, + }, + patroni: { + dynamicConfiguration: { + standby_cluster: { + host: overridden, + port: 0000, + restore_command: overridden, + unrelated: input, + }, }, }, - }, - input: map[string]any{ - "standby_cluster": map[string]any{ - "host": "overridden", - "port": int32(0000), - "restore_command": "overridden", - "unrelated": "input", - }, - }, - params: postgres.Parameters{ - Mandatory: parameters(map[string]string{ - "restore_command": "mandatory", - }), - }, + }`, + params: parameters(map[string]string{ + "restore_command": "mandatory", + }), expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{ + "parameters": map[string]string{ "restore_command": "mandatory", }, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, @@ -661,37 +485,34 @@ func TestDynamicConfiguration(t *testing.T) { }, { name: "standby_cluster: both repo and streaming", - cluster: &v1beta1.PostgresCluster{ - Spec: v1beta1.PostgresClusterSpec{ - Standby: &v1beta1.PostgresStandbySpec{ - Enabled: true, - Host: "0.0.0.0", - Port: initialize.Int32(5432), - RepoName: "repo", + spec: `{ + standby: { + enabled: true, + host: 0.0.0.0, + port: 5432, + repoName: repo, + }, + patroni: { + dynamicConfiguration: { + standby_cluster: { + host: overridden, + port: 9999, + restore_command: overridden, + unrelated: input, + }, }, }, - }, - input: map[string]any{ - "standby_cluster": map[string]any{ - "host": "overridden", - "port": int32(9999), - "restore_command": "overridden", - "unrelated": "input", - }, - }, - params: postgres.Parameters{ - Mandatory: parameters(map[string]string{ - "restore_command": "mandatory", - }), - }, + }`, + params: parameters(map[string]string{ + "restore_command": "mandatory", + }), expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "parameters": map[string]any{ + "parameters": map[string]string{ "restore_command": "mandatory", }, - "pg_hba": []string{}, "use_pg_rewind": true, "use_slots": false, }, @@ -705,27 +526,25 @@ func TestDynamicConfiguration(t *testing.T) { }, }, { - name: "tde enabled", - cluster: &v1beta1.PostgresCluster{ - Spec: v1beta1.PostgresClusterSpec{ - Patroni: &v1beta1.PatroniSpec{ - DynamicConfiguration: map[string]any{ - "postgresql": map[string]any{ - "parameters": map[string]any{ - "encryption_key_command": "echo test", - }, - }, - }, + name: "config.parameters: tde enabled", + spec: `{ + config: { + parameters: { + encryption_key_command: echo one, }, }, - }, + }`, + params: parameters(map[string]string{ + "encryption_key_command": "echo one", + }), expected: map[string]any{ "loop_wait": int32(10), "ttl": int32(30), "postgresql": map[string]any{ - "bin_name": map[string]any{"pg_rewind": string("/tmp/pg_rewind_tde.sh")}, - "parameters": map[string]any{}, - "pg_hba": []string{}, + "bin_name": map[string]any{"pg_rewind": string("/tmp/pg_rewind_tde.sh")}, + "parameters": map[string]string{ + "encryption_key_command": "echo one", + }, "use_pg_rewind": bool(true), "use_slots": bool(false), }, @@ -733,15 +552,13 @@ func TestDynamicConfiguration(t *testing.T) { }, } { t.Run(tt.name, func(t *testing.T) { - cluster := tt.cluster - if cluster == nil { - cluster = new(v1beta1.PostgresCluster) - } + cluster := new(v1beta1.PostgresCluster) + require.UnmarshalInto(t, &cluster.Spec, tt.spec) if cluster.Spec.PostgresVersion == 0 { cluster.Spec.PostgresVersion = 14 } cluster.Default() - actual := DynamicConfiguration(cluster, tt.input, tt.hbas, tt.params) + actual := DynamicConfiguration(&cluster.Spec, tt.hbas, tt.params) assert.DeepEqual(t, tt.expected, actual) }) } @@ -903,10 +720,9 @@ postgresql: - pgbackrest - basebackup pgbackrest: - command: '''bash'' ''-ceu'' ''--'' ''install --directory --mode=0700 "${PGDATA?}" - && exec "$@"'' ''-'' ''some'' ''backrest'' ''cmd''' + command: '''some'' ''backrest'' ''cmd''' keep_data: true - no_master: true + no_leader: true no_params: true pgpass: /tmp/.pgpass use_unix_socket: true @@ -969,37 +785,14 @@ func TestPGBackRestCreateReplicaCommand(t *testing.T) { } assert.NilError(t, yaml.Unmarshal([]byte(data), &parsed)) - dir := t.TempDir() + assert.Equal(t, parsed.PostgreSQL.PGBackRest.Command, `'some' 'backrest' 'cmd'`) // The command should be compatible with any shell. { - command := parsed.PostgreSQL.PGBackRest.Command - file := filepath.Join(dir, "command.sh") - assert.NilError(t, os.WriteFile(file, []byte(command), 0o600)) - - cmd := exec.Command(shellcheck, "--enable=all", "--shell=sh", file) - output, err := cmd.CombinedOutput() - assert.NilError(t, err, "%q\n%s", cmd.Args, output) - } - - // Naive parsing of shell words... - command := strings.Split(strings.Trim(parsed.PostgreSQL.PGBackRest.Command, "'"), "' '") - - // Expect a bash command with an inline script. - assert.DeepEqual(t, command[:3], []string{"bash", "-ceu", "--"}) - assert.Assert(t, len(command) > 3) - script := command[3] - - // It should call the pgBackRest command. - assert.Assert(t, strings.HasSuffix(script, ` exec "$@"`)) - assert.DeepEqual(t, command[len(command)-3:], []string{"some", "backrest", "cmd"}) - - // It should pass shellcheck. - { - file := filepath.Join(dir, "script.bash") - assert.NilError(t, os.WriteFile(file, []byte(script), 0o600)) + file := filepath.Join(t.TempDir(), "command.sh") + assert.NilError(t, os.WriteFile(file, []byte(parsed.PostgreSQL.PGBackRest.Command), 0o600)) - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", "--shell=sh", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) } @@ -1078,7 +871,6 @@ func TestProbeTiming(t *testing.T) { FailureThreshold: 1, }}, } { - tt := tt actual := probeTiming(&v1beta1.PatroniSpec{ LeaderLeaseDurationSeconds: &tt.lease, SyncPeriodSeconds: &tt.sync, diff --git a/internal/patroni/doc.go b/internal/patroni/doc.go index 500305406d..035cdee7fe 100644 --- a/internal/patroni/doc.go +++ b/internal/patroni/doc.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/patroni/postgres.go b/internal/patroni/postgres.go new file mode 100644 index 0000000000..519fc30c04 --- /dev/null +++ b/internal/patroni/postgres.go @@ -0,0 +1,83 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package patroni + +import ( + "encoding/json" + "fmt" + + "github.com/crunchydata/postgres-operator/internal/postgres" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// PostgresHBAs returns the HBA rules in spec, if any. +func PostgresHBAs(spec *v1beta1.PatroniSpec) []string { + var result []string + + if spec != nil { + // DynamicConfiguration lacks an OpenAPI schema, so it may contain any type + // at any depth. Navigate the object and skip HBA values that aren't string. + // + // Patroni expects a list of strings: + // https://github.com/patroni/patroni/blob/v4.0.0/patroni/validator.py#L1170 + // + if root := spec.DynamicConfiguration; root != nil { + if postgresql, ok := root["postgresql"].(map[string]any); ok { + if section, ok := postgresql["pg_hba"].([]any); ok { + for i := range section { + if value, ok := section[i].(string); ok { + result = append(result, value) + } + } + } + } + } + } + + return result +} + +// PostgresParameters returns the Postgres parameters in spec, if any. +func PostgresParameters(spec *v1beta1.PatroniSpec) *postgres.ParameterSet { + result := postgres.NewParameterSet() + + if spec != nil { + // DynamicConfiguration lacks an OpenAPI schema, so it may contain any type + // at any depth. Navigate the object and convert parameter values to string. + // + // Patroni accepts booleans, integers, and strings but also parses + // string values into the types it expects: + // https://github.com/patroni/patroni/blob/v4.0.0/patroni/postgresql/validator.py + // + // Patroni passes JSON arrays and objects through Python str() which looks + // similar to YAML in simple cases: + // https://github.com/patroni/patroni/blob/v4.0.0/patroni/postgresql/config.py#L254-L259 + // + // >>> str(list((1, 2.3, True, "asdf"))) + // "[1, 2.3, True, 'asdf']" + // + // >>> str(dict(a = 1, b = True)) + // "{'a': 1, 'b': True}" + // + if root := spec.DynamicConfiguration; root != nil { + if postgresql, ok := root["postgresql"].(map[string]any); ok { + if section, ok := postgresql["parameters"].(map[string]any); ok { + for k, v := range section { + switch v.(type) { + case []any, map[string]any: + if b, err := json.Marshal(v); err == nil { + result.Add(k, string(b)) + } + default: + result.Add(k, fmt.Sprint(v)) + } + } + } + } + } + } + + return result +} diff --git a/internal/patroni/postgres_test.go b/internal/patroni/postgres_test.go new file mode 100644 index 0000000000..becd1b1743 --- /dev/null +++ b/internal/patroni/postgres_test.go @@ -0,0 +1,197 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package patroni + +import ( + "testing" + + "gotest.tools/v3/assert" + + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestPostgresHBAs(t *testing.T) { + t.Run("Zero", func(t *testing.T) { + result := PostgresHBAs(nil) + + assert.Assert(t, result == nil) + }) + + t.Run("NoDynamicConfig", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + result := PostgresHBAs(spec) + + assert.Assert(t, result == nil) + }) + + t.Run("NoPostgreSQL", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: {}, + }`) + + result := PostgresHBAs(spec) + assert.Assert(t, result == nil) + + t.Run("WrongType", func(t *testing.T) { + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: { + postgresql: asdf, + }, + }`) + + result := PostgresHBAs(spec) + assert.Assert(t, result == nil) + }) + }) + + t.Run("NoHBAs", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: { + postgresql: { + use_pg_rewind: true, + }, + }, + }`) + + result := PostgresHBAs(spec) + assert.Assert(t, result == nil) + + t.Run("WrongType", func(t *testing.T) { + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: { + postgresql: { + pg_hba: asdf, + }, + }, + }`) + + result := PostgresHBAs(spec) + assert.Assert(t, result == nil) + }) + }) + + t.Run("HBAs", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: { + postgresql: { + pg_hba: [ + "host all all all trust", + true, + "total garbage, yikes", + 123, + ], + }, + }, + }`) + + result := PostgresHBAs(spec) + assert.DeepEqual(t, result, []string{ + "host all all all trust", + "total garbage, yikes", + }) + }) +} + +func TestPostgresParameters(t *testing.T) { + t.Run("Zero", func(t *testing.T) { + result := PostgresParameters(nil) + + assert.Assert(t, result != nil) + assert.DeepEqual(t, result.AsMap(), map[string]string{}) + }) + + t.Run("NoDynamicConfig", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + result := PostgresParameters(spec) + + assert.Assert(t, result != nil) + assert.DeepEqual(t, result.AsMap(), map[string]string{}) + }) + + t.Run("NoPostgreSQL", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: {}, + }`) + result := PostgresParameters(spec) + + assert.Assert(t, result != nil) + assert.DeepEqual(t, result.AsMap(), map[string]string{}) + }) + + t.Run("WrongPostgreSQLType", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: { + postgresql: asdf, + }, + }`) + result := PostgresParameters(spec) + + assert.Assert(t, result != nil) + assert.DeepEqual(t, result.AsMap(), map[string]string{}) + }) + + t.Run("NoParameters", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: { + postgresql: { + use_pg_rewind: true, + }, + }, + }`) + result := PostgresParameters(spec) + + assert.Assert(t, result != nil) + assert.DeepEqual(t, result.AsMap(), map[string]string{}) + }) + + t.Run("WrongParametersType", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: { + postgresql: { + parameters: [1,2], + }, + }, + }`) + result := PostgresParameters(spec) + + assert.Assert(t, result != nil) + assert.DeepEqual(t, result.AsMap(), map[string]string{}) + }) + + t.Run("Parameters", func(t *testing.T) { + spec := new(v1beta1.PatroniSpec) + require.UnmarshalInto(t, spec, `{ + dynamicConfiguration: { + postgresql: { + parameters: { + log_statement_sample_rate: 0.98, + max_connections: 1000, + wal_log_hints: true, + wal_level: replica, + strange.though: [ 1, 2.3, yes ], + }, + }, + }, + }`) + result := PostgresParameters(spec) + + assert.Assert(t, result != nil) + assert.DeepEqual(t, result.AsMap(), map[string]string{ + "log_statement_sample_rate": "0.98", + "max_connections": "1000", + "wal_log_hints": "true", + "wal_level": "replica", + "strange.though": "[1,2.3,true]", + }) + }) +} diff --git a/internal/patroni/rbac.go b/internal/patroni/rbac.go index dcf3f18cea..4b0392c38d 100644 --- a/internal/patroni/rbac.go +++ b/internal/patroni/rbac.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/patroni/rbac_test.go b/internal/patroni/rbac_test.go index 39a8dff245..587d97bea6 100644 --- a/internal/patroni/rbac_test.go +++ b/internal/patroni/rbac_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/patroni/reconcile.go b/internal/patroni/reconcile.go index 4fbb08b67d..a8de99f028 100644 --- a/internal/patroni/reconcile.go +++ b/internal/patroni/reconcile.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -29,16 +29,17 @@ func ClusterBootstrapped(postgresCluster *v1beta1.PostgresCluster) bool { // ClusterConfigMap populates the shared ConfigMap with fields needed to run Patroni. func ClusterConfigMap(ctx context.Context, inCluster *v1beta1.PostgresCluster, - inHBAs postgres.HBAs, - inParameters postgres.Parameters, + inHBAs *postgres.OrderedHBAs, + inParameters *postgres.ParameterSet, outClusterConfigMap *corev1.ConfigMap, + patroniLogStorageLimit int64, ) error { var err error initialize.Map(&outClusterConfigMap.Data) outClusterConfigMap.Data[configMapFileKey], err = clusterYAML(inCluster, inHBAs, - inParameters) + inParameters, patroniLogStorageLimit) return err } @@ -171,8 +172,7 @@ func instanceProbes(cluster *v1beta1.PostgresCluster, container *corev1.Containe } // PodIsPrimary returns whether or not pod is currently acting as the leader with -// the "master" role. This role will be called "primary" in the future, see: -// - https://github.com/zalando/patroni/blob/master/docs/releases.rst?plain=1#L213 +// the "primary" role. func PodIsPrimary(pod metav1.Object) bool { if pod == nil { return false @@ -185,7 +185,8 @@ func PodIsPrimary(pod metav1.Object) bool { // - https://github.com/zalando/patroni/blob/v3.1.1/patroni/ha.py#L782 // - https://github.com/zalando/patroni/blob/v3.1.1/patroni/ha.py#L1574 status := pod.GetAnnotations()["status"] - return strings.Contains(status, `"role":"master"`) + return strings.Contains(status, `"role":"master"`) || + strings.Contains(status, `"role":"primary"`) } // PodIsStandbyLeader returns whether or not pod is currently acting as a "standby_leader". diff --git a/internal/patroni/reconcile_test.go b/internal/patroni/reconcile_test.go index 5d2a2c0ad5..729bd6573d 100644 --- a/internal/patroni/reconcile_test.go +++ b/internal/patroni/reconcile_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -14,7 +14,6 @@ import ( "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/pki" - "github.com/crunchydata/postgres-operator/internal/postgres" "github.com/crunchydata/postgres-operator/internal/testing/cmp" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -24,20 +23,18 @@ func TestClusterConfigMap(t *testing.T) { ctx := context.Background() cluster := new(v1beta1.PostgresCluster) - pgHBAs := postgres.HBAs{} - pgParameters := postgres.Parameters{} - cluster.Default() + config := new(corev1.ConfigMap) - assert.NilError(t, ClusterConfigMap(ctx, cluster, pgHBAs, pgParameters, config)) + assert.NilError(t, ClusterConfigMap(ctx, cluster, nil, nil, config, 0)) // The output of clusterYAML should go into config. - data, _ := clusterYAML(cluster, pgHBAs, pgParameters) + data, _ := clusterYAML(cluster, nil, nil, 0) assert.DeepEqual(t, config.Data["patroni.yaml"], data) // No change when called again. before := config.DeepCopy() - assert.NilError(t, ClusterConfigMap(ctx, cluster, pgHBAs, pgParameters, config)) + assert.NilError(t, ClusterConfigMap(ctx, cluster, nil, nil, config, 0)) assert.DeepEqual(t, config, before) } @@ -241,7 +238,7 @@ func TestPodIsPrimary(t *testing.T) { assert.Assert(t, !PodIsPrimary(pod)) // Primary - pod.Annotations["status"] = `{"role":"master"}` + pod.Annotations["status"] = `{"role":"primary"}` assert.Assert(t, PodIsPrimary(pod)) } @@ -258,7 +255,7 @@ func TestPodIsStandbyLeader(t *testing.T) { assert.Assert(t, !PodIsStandbyLeader(pod)) // Leader - pod.Annotations["status"] = `{"role":"master"}` + pod.Annotations["status"] = `{"role":"primary"}` assert.Assert(t, !PodIsStandbyLeader(pod)) // Replica diff --git a/internal/pgadmin/config.go b/internal/pgadmin/config.go index 553a90f656..1715ee19bc 100644 --- a/internal/pgadmin/config.go +++ b/internal/pgadmin/config.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -9,6 +9,7 @@ import ( corev1 "k8s.io/api/core/v1" + "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -94,16 +95,9 @@ func podConfigFiles(configmap *corev1.ConfigMap, spec v1beta1.PGAdminPodSpec) [] // - https://www.pgadmin.org/docs/pgadmin4/development/enabling_ldap_authentication.html if spec.Config.LDAPBindPassword != nil { config = append(config, corev1.VolumeProjection{ - Secret: &corev1.SecretProjection{ - LocalObjectReference: spec.Config.LDAPBindPassword.LocalObjectReference, - Optional: spec.Config.LDAPBindPassword.Optional, - Items: []corev1.KeyToPath{ - { - Key: spec.Config.LDAPBindPassword.Key, - Path: ldapPasswordPath, - }, - }, - }, + Secret: initialize.Pointer( + spec.Config.LDAPBindPassword.AsProjection(ldapPasswordPath), + ), }) } @@ -159,10 +153,10 @@ if os.path.isfile('` + ldapPasswordAbsolutePath + `'): } // systemSettings returns pgAdmin settings as a value that can be marshaled to JSON. -func systemSettings(spec *v1beta1.PGAdminPodSpec) map[string]interface{} { - settings := *spec.Config.Settings.DeepCopy() +func systemSettings(spec *v1beta1.PGAdminPodSpec) map[string]any { + settings := spec.Config.Settings.DeepCopy() if settings == nil { - settings = make(map[string]interface{}) + settings = make(map[string]any) } // SERVER_MODE must always be enabled when running on a webserver. diff --git a/internal/pgadmin/config_test.go b/internal/pgadmin/config_test.go index 87cd7847c2..7d072e1b5e 100644 --- a/internal/pgadmin/config_test.go +++ b/internal/pgadmin/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -77,7 +77,7 @@ func TestStartupCommand(t *testing.T) { assert.NilError(t, os.WriteFile(file, []byte(command[3]), 0o600)) // Expect shellcheck to be happy. - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) }) @@ -94,7 +94,7 @@ func TestStartupCommand(t *testing.T) { // Expect flake8 to be happy. Ignore "E401 multiple imports on one line" // in addition to the defaults. The file contents appear in PodSpec, so // allow lines longer than the default to save some vertical space. - cmd := exec.Command(flake8, "--extend-ignore=E401", "--max-line-length=99", file) + cmd := exec.CommandContext(t.Context(), flake8, "--extend-ignore=E401", "--max-line-length=99", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) }) @@ -106,8 +106,8 @@ func TestSystemSettings(t *testing.T) { SERVER_MODE: true `)) - spec.Config.Settings = map[string]interface{}{ - "ALLOWED_HOSTS": []interface{}{"225.0.0.0/8", "226.0.0.0/7", "228.0.0.0/6"}, + spec.Config.Settings = map[string]any{ + "ALLOWED_HOSTS": []any{"225.0.0.0/8", "226.0.0.0/7", "228.0.0.0/6"}, } assert.Assert(t, cmp.MarshalMatches(systemSettings(spec), ` ALLOWED_HOSTS: diff --git a/internal/pgadmin/reconcile.go b/internal/pgadmin/reconcile.go index af62c482f2..cefb179dc2 100644 --- a/internal/pgadmin/reconcile.go +++ b/internal/pgadmin/reconcile.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgadmin/reconcile_test.go b/internal/pgadmin/reconcile_test.go index f91a9b807f..b9091edf37 100644 --- a/internal/pgadmin/reconcile_test.go +++ b/internal/pgadmin/reconcile_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -47,7 +47,7 @@ pgadmin-settings.json: | t.Run("Customizations", func(t *testing.T) { cluster.Spec.UserInterface = new(v1beta1.UserInterfaceSpec) cluster.Spec.UserInterface.PGAdmin = new(v1beta1.PGAdminPodSpec) - cluster.Spec.UserInterface.PGAdmin.Config.Settings = map[string]interface{}{ + cluster.Spec.UserInterface.PGAdmin.Config.Settings = map[string]any{ "some": "thing", "UPPER_CASE": false, } @@ -316,11 +316,11 @@ volumes: Name: "test", }}, }} - cluster.Spec.UserInterface.PGAdmin.Config.LDAPBindPassword = &corev1.SecretKeySelector{ - LocalObjectReference: corev1.LocalObjectReference{ + cluster.Spec.UserInterface.PGAdmin.Config.LDAPBindPassword = &v1beta1.OptionalSecretKeyRef{ + SecretKeyRef: v1beta1.SecretKeyRef{ Name: "podtest", + Key: "podtestpw", }, - Key: "podtestpw", } call() diff --git a/internal/pgadmin/users.go b/internal/pgadmin/users.go index 7ce69ce211..5e9c07a934 100644 --- a/internal/pgadmin/users.go +++ b/internal/pgadmin/users.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -237,9 +237,9 @@ with create_app().app_context():`, spec := users[i] if err == nil { - err = encoder.Encode(map[string]interface{}{ + err = encoder.Encode(map[string]any{ "username": spec.Name, - "password": passwords[string(spec.Name)], + "password": passwords[spec.Name], }) } } diff --git a/internal/pgadmin/users_test.go b/internal/pgadmin/users_test.go index 69619667af..343f54e581 100644 --- a/internal/pgadmin/users_test.go +++ b/internal/pgadmin/users_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -180,7 +180,7 @@ with create_app().app_context(): // Expect flake8 to be happy. Ignore "E402 module level import not // at top of file" in addition to the defaults. - cmd := exec.Command(flake8, "--extend-ignore=E402", file) + cmd := exec.CommandContext(t.Context(), flake8, "--extend-ignore=E402", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) @@ -235,7 +235,7 @@ with create_app().app_context(): []v1beta1.PostgresUserSpec{ { Name: "user-no-options", - Databases: []v1beta1.PostgresIdentifier{"db1"}, + Databases: []string{"db1"}, }, { Name: "user-no-databases", diff --git a/internal/pgaudit/postgres.go b/internal/pgaudit/postgres.go index 07867d020e..27a0ffd720 100644 --- a/internal/pgaudit/postgres.go +++ b/internal/pgaudit/postgres.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -35,7 +35,9 @@ func EnableInPostgreSQL(ctx context.Context, exec postgres.Executor) error { stdout, stderr, err := exec.ExecInAllDatabases(ctx, // Quiet the NOTICE from IF EXISTS, and install the pgAudit event triggers. + // Use the default setting for "synchronous_commit". // - https://www.postgresql.org/docs/current/runtime-config-client.html + // - https://www.postgresql.org/docs/current/runtime-config-wal.html // - https://github.com/pgaudit/pgaudit#settings `SET client_min_messages = WARNING; CREATE EXTENSION IF NOT EXISTS pgaudit;`, map[string]string{ diff --git a/internal/pgaudit/postgres_test.go b/internal/pgaudit/postgres_test.go index 3734e511f0..62854793ea 100644 --- a/internal/pgaudit/postgres_test.go +++ b/internal/pgaudit/postgres_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/certificates.go b/internal/pgbackrest/certificates.go index bb2633dfe7..88262a3074 100644 --- a/internal/pgbackrest/certificates.go +++ b/internal/pgbackrest/certificates.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/certificates.md b/internal/pgbackrest/certificates.md index 344616486b..898cf512cd 100644 --- a/internal/pgbackrest/certificates.md +++ b/internal/pgbackrest/certificates.md @@ -1,5 +1,5 @@ diff --git a/internal/pgbackrest/certificates_test.go b/internal/pgbackrest/certificates_test.go index 4ef41b2879..3f9e157f81 100644 --- a/internal/pgbackrest/certificates_test.go +++ b/internal/pgbackrest/certificates_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/config.go b/internal/pgbackrest/config.go index f50b2690ee..f023aa77d2 100644 --- a/internal/pgbackrest/config.go +++ b/internal/pgbackrest/config.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,16 +7,20 @@ package pgbackrest import ( "context" "fmt" + "path" "strconv" "strings" + "time" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/postgres" + "github.com/crunchydata/postgres-operator/internal/shell" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -34,6 +38,10 @@ const ( // repository host CMRepoKey = "pgbackrest_repo.conf" + // CMCloudRepoKey is the name of the pgBackRest configuration file used by backup jobs + // for cloud repos + CMCloudRepoKey = "pgbackrest_cloud.conf" + // configDirectory is the pgBackRest configuration directory. configDirectory = "/etc/pgbackrest/conf.d" @@ -65,9 +73,12 @@ const ( // pgbackrest_job.conf is used by certain jobs, such as stanza create and backup // pgbackrest_primary.conf is used by the primary database pod // pgbackrest_repo.conf is used by the pgBackRest repository pod -func CreatePGBackRestConfigMapIntent(postgresCluster *v1beta1.PostgresCluster, - repoHostName, configHash, serviceName, serviceNamespace string, - instanceNames []string) *corev1.ConfigMap { +// pgbackrest_cloud.conf is used by cloud repo backup jobs +func CreatePGBackRestConfigMapIntent(ctx context.Context, postgresCluster *v1beta1.PostgresCluster, + repoHostName, configHash, serviceName, serviceNamespace, cloudLogPath string, + instanceNames []string) (*corev1.ConfigMap, error) { + + var err error meta := naming.PGBackRestConfig(postgresCluster) meta.Annotations = naming.Merge( @@ -102,17 +113,14 @@ func CreatePGBackRestConfigMapIntent(postgresCluster *v1beta1.PostgresCluster, postgresCluster.Spec.Backups.PGBackRest.Global, ).String() + // As the cluster transitions from having a repository host to having none, // PostgreSQL instances that have not rolled out expect to mount a server // config file. Always populate that file so those volumes stay valid and - // Kubernetes propagates their contents to those pods. The repo host name - // given below should always be set, but this guards for cases when it might - // not be. - cm.Data[serverConfigMapKey] = "" - - if repoHostName != "" { - cm.Data[serverConfigMapKey] = iniGeneratedWarning + - serverConfig(postgresCluster).String() + // Kubernetes propagates their contents to those pods. + cm.Data[serverConfigMapKey] = iniGeneratedWarning + + serverConfig(postgresCluster).String() + if RepoHostVolumeDefined(postgresCluster) && repoHostName != "" { cm.Data[CMRepoKey] = iniGeneratedWarning + populateRepoHostConfigurationMap( serviceName, serviceNamespace, @@ -122,17 +130,54 @@ func CreatePGBackRestConfigMapIntent(postgresCluster *v1beta1.PostgresCluster, postgresCluster.Spec.Backups.PGBackRest.Repos, postgresCluster.Spec.Backups.PGBackRest.Global, ).String() + + if collector.OpenTelemetryLogsOrMetricsEnabled(ctx, postgresCluster) { + + err = collector.AddToConfigMap(ctx, collector.NewConfigForPgBackrestRepoHostPod( + ctx, + postgresCluster.Spec.Instrumentation, + postgresCluster.Spec.Backups.PGBackRest.Repos, + ), cm) + + // If OTel logging is enabled, add logrotate config for the RepoHost + if err == nil && + collector.OpenTelemetryLogsEnabled(ctx, postgresCluster) { + var pgBackRestLogPath string + for _, repo := range postgresCluster.Spec.Backups.PGBackRest.Repos { + if repo.Volume != nil { + pgBackRestLogPath = fmt.Sprintf(naming.PGBackRestRepoLogPath, repo.Name) + break + } + } + + collector.AddLogrotateConfigs(ctx, postgresCluster.Spec.Instrumentation, cm, []collector.LogrotateConfig{{ + LogFiles: []string{pgBackRestLogPath + "/*.log"}, + }}) + } + } + } + + if CloudRepoDefined(postgresCluster) { + cm.Data[CMCloudRepoKey] = iniGeneratedWarning + + populateCloudRepoConfigurationMap( + serviceName, serviceNamespace, pgdataDir, + config.FetchKeyCommand(&postgresCluster.Spec), + strconv.Itoa(postgresCluster.Spec.PostgresVersion), + cloudLogPath, pgPort, instanceNames, + postgresCluster.Spec.Backups.PGBackRest.Repos, + postgresCluster.Spec.Backups.PGBackRest.Global, + ).String() } cm.Data[ConfigHashKey] = configHash - return cm + return cm, err } // MakePGBackrestLogDir creates the pgBackRest default log path directory used when a // dedicated repo host is configured. func MakePGBackrestLogDir(template *corev1.PodTemplateSpec, - cluster *v1beta1.PostgresCluster) { + cluster *v1beta1.PostgresCluster) string { var pgBackRestLogPath string for _, repo := range cluster.Spec.Backups.PGBackRest.Repos { @@ -143,7 +188,9 @@ func MakePGBackrestLogDir(template *corev1.PodTemplateSpec, } container := corev1.Container{ - Command: []string{"bash", "-c", "mkdir -p " + pgBackRestLogPath}, + // TODO(log-rotation): The second argument here should be the path + // of the volume mount. Find a way to calculate that consistently. + Command: []string{"bash", "-c", shell.MakeDirectories(path.Dir(pgBackRestLogPath), pgBackRestLogPath)}, Image: config.PGBackRestContainerImage(cluster), ImagePullPolicy: cluster.Spec.ImagePullPolicy, Name: naming.ContainerPGBackRestLogDirInit, @@ -158,6 +205,8 @@ func MakePGBackrestLogDir(template *corev1.PodTemplateSpec, } } template.Spec.InitContainers = append(template.Spec.InitContainers, container) + + return pgBackRestLogPath } // RestoreCommand returns the command for performing a pgBackRest restore. In addition to calling @@ -171,96 +220,121 @@ func MakePGBackrestLogDir(template *corev1.PodTemplateSpec, // - Renames the data directory as needed to bootstrap the cluster using the restored database. // This ensures compatibility with the "existing" bootstrap method that is included in the // Patroni config when bootstrapping a cluster using an existing data directory. -func RestoreCommand(pgdata, hugePagesSetting, fetchKeyCommand string, tablespaceVolumes []*corev1.PersistentVolumeClaim, args ...string) []string { - - // After pgBackRest restores files, PostgreSQL starts in recovery to finish - // replaying WAL files. "hot_standby" is "on" (by default) so we can detect - // when recovery has finished. In that mode, some parameters cannot be - // smaller than they were when PostgreSQL was backed up. Configure them to - // match the values reported by "pg_controldata". Those parameters are also - // written to WAL files and may change during recovery. When they increase, - // PostgreSQL exits and we reconfigure and restart it. - // For PG14, when some parameters from WAL require a restart, the behavior is - // to pause unless a restart is requested. For this edge case, we run a CASE - // query to check - // (a) if the instance is in recovery; - // (b) if so, if the WAL replay is paused; - // (c) if so, to unpause WAL replay, allowing our expected behavior to resume. - // A note on the PostgreSQL code: we cast `pg_catalog.pg_wal_replay_resume()` as text - // because that method returns a void (which is a non-NULL but empty result). When - // that void is cast as a string, it is an '' - // - https://www.postgresql.org/docs/current/hot-standby.html - // - https://www.postgresql.org/docs/current/app-pgcontroldata.html +func RestoreCommand(pgdata, hugePagesSetting, fetchKeyCommand string, _ []*corev1.PersistentVolumeClaim, args ...string) []string { + ps := postgres.NewParameterSet() + ps.Add("data_directory", pgdata) + ps.Add("huge_pages", hugePagesSetting) - // The postmaster.pid file is removed, if it exists, before attempting a restore. - // This allows the restore to be tried more than once without the causing an - // error due to the presence of the file in subsequent attempts. - - // The 'pg_ctl' timeout is set to a very large value (1 year) to ensure there - // are no timeouts when starting or stopping Postgres. + // Keep history and WAL files until the cluster starts with its normal + // archiving enabled. + ps.Add("archive_command", "false -- store WAL files locally for now") + ps.Add("archive_mode", "on") - tablespaceCmd := "" - for _, tablespaceVolume := range tablespaceVolumes { - tablespaceCmd = tablespaceCmd + fmt.Sprintf( - "\ninstall --directory --mode=0700 '/tablespaces/%s/data'", - tablespaceVolume.Labels[naming.LabelData]) - } + // Enable "hot_standby" so we can connect to Postgres and observe its + // progress during recovery. + ps.Add("hot_standby", "on") - // If the fetch key command is not empty, save the GUC variable and value - // to a new string. - var ekc string if fetchKeyCommand != "" { - ekc = ` -encryption_key_command = '` + fetchKeyCommand + `'` + ps.Add("encryption_key_command", fetchKeyCommand) } - restoreScript := `declare -r pgdata="$1" opts="$2" -install --directory --mode=0700 "${pgdata}"` + tablespaceCmd + ` -rm -f "${pgdata}/postmaster.pid" -bash -xc "pgbackrest restore ${opts}" -rm -f "${pgdata}/patroni.dynamic.json" -export PGDATA="${pgdata}" PGHOST='/tmp' - -until [[ "${recovery=}" == 'f' ]]; do -if [[ -z "${recovery}" ]]; then -control=$(pg_controldata) -read -r max_conn <<< "${control##*max_connections setting:}" -read -r max_lock <<< "${control##*max_locks_per_xact setting:}" -read -r max_ptxn <<< "${control##*max_prepared_xacts setting:}" -read -r max_work <<< "${control##*max_worker_processes setting:}" -echo > /tmp/pg_hba.restore.conf 'local all "postgres" peer' -cat > /tmp/postgres.restore.conf <> /tmp/postgres.restore.conf "max_wal_senders = '${max_wals}'" -fi - -pg_ctl start --silent --timeout=31536000 --wait --options='--config-file=/tmp/postgres.restore.conf' -fi - -recovery=$(psql -Atc "SELECT CASE - WHEN NOT pg_catalog.pg_is_in_recovery() THEN false - WHEN NOT pg_catalog.pg_is_wal_replay_paused() THEN true - ELSE pg_catalog.pg_wal_replay_resume()::text = '' -END recovery" && sleep 1) ||: -done - -pg_ctl stop --silent --wait --timeout=31536000 -mv "${pgdata}" "${pgdata}_bootstrap"` - - return append([]string{"bash", "-ceu", "--", restoreScript, "-", pgdata}, args...) + configure := strings.Join([]string{ + // With "hot_standby" on, some parameters cannot be smaller than they were + // when Postgres was backed up. Configure these to match values reported by + // "pg_controldata" before starting Postgres. These parameters are also + // written to WAL files and may change during recovery. When they increase, + // Postgres exits and we reconfigure it here. + // - https://www.postgresql.org/docs/current/app-pgcontroldata.html + `control=$(LC_ALL=C pg_controldata)`, + `read -r max_conn <<< "${control##*max_connections setting:}"`, + `read -r max_lock <<< "${control##*max_locks_per_xact setting:}"`, + `read -r max_ptxn <<< "${control##*max_prepared_xacts setting:}"`, + `read -r max_work <<< "${control##*max_worker_processes setting:}"`, + + // During recovery, only allow connections over the domain socket. + `echo > /tmp/pg_hba.restore.conf 'local all "postgres" peer'`, + + // Combine parameters from Go with those detected in Bash. + `cat > /tmp/postgres.restore.conf <<'EOF'`, ps.String(), `EOF`, + `cat >> /tmp/postgres.restore.conf <> /tmp/postgres.restore.conf "max_wal_senders = '${max_wals}'"`, + `fi`, + + // TODO(sockets): PostgreSQL v14 is able to connect over abstract sockets in the network namespace. + `PGHOST=$([[ "${version}" -ge 14 ]] && echo '/tmp' || echo '/tmp')`, + `echo >> /tmp/postgres.restore.conf "unix_socket_directories = '${PGHOST}'"`, + }, "\n") + + script := strings.Join([]string{ + `declare -r PGDATA="$1" opts="$2"; export PGDATA PGHOST`, + + // Remove any "postmaster.pid" file leftover from a prior failure. + `rm -f "${PGDATA}/postmaster.pid"`, + + // Run the restore and print its arguments. + `bash -xc "pgbackrest restore ${opts}"`, + + // Ignore any Patroni settings present in the backup. + `rm -f "${PGDATA}/patroni.dynamic.json"`, + + // By default, pg_ctl waits 60 seconds for Postgres to stop or start. + // We want to be certain when Postgres is running or not, so we use + // a very large timeout (365 days) to effectively wait forever. With + // this, the result of "pg_ctl --wait" indicates the state of Postgres. + // - https://www.postgresql.org/docs/current/app-pg-ctl.html + fmt.Sprintf(`export PGCTLTIMEOUT=%d`, 365*24*time.Hour/time.Second), + + // Configure and start Postgres until we can see that it has finished + // replaying WAL. + // + // PostgreSQL v13 and earlier exit when they need reconfiguration with + // "hot_standby" on. This can cause pg_ctl to fail, so we compare the + // LSN from before and after calling it. If the LSN changed, Postgres + // ran and was able to replay WAL before exiting. In that case, configure + // Postgres and start it again to see if it can make more progress. + // + // If Postgres exits after pg_ctl succeeds, psql returns nothing which + // resets the "recovering" variable. Configure Postgres and start it again. + `until [[ "${recovering=}" == 'f' ]]; do`, + ` if [[ -z "${recovering}" ]]; then`, configure, + ` read -r stopped <<< "${control##*recovery ending location:}"`, + ` pg_ctl start --silent --wait --options='-c config_file=/tmp/postgres.restore.conf' || failed=$?`, + ` [[ "${started-}" == "${stopped}" && -n "${failed-}" ]] && exit "${failed}"`, + ` started="${stopped}" && [[ -n "${failed-}" ]] && failed= && continue`, + ` fi`, + // Ask Postgres if it is still recovering. PostgreSQL v14 pauses when it + // needs reconfiguration with "hot_standby" on, and resuming replay causes + // it to exit like prior versions. + // - https://www.postgresql.org/docs/current/hot-standby.html + // + // NOTE: "pg_wal_replay_resume()" returns void which cannot be compared to + // null. Instead, cast it to text and compare that for a boolean result. + ` recovering=$(psql -Atc "SELECT CASE`, + ` WHEN NOT pg_catalog.pg_is_in_recovery() THEN false`, + ` WHEN NOT pg_catalog.pg_is_wal_replay_paused() THEN true`, + ` ELSE pg_catalog.pg_wal_replay_resume()::text = ''`, + ` END" && sleep 1) ||:`, + `done`, + + // Replay is done. Stop Postgres gracefully and move the data directory + // into position for our Patroni bootstrap method. + `pg_ctl stop --silent --wait`, + `mv "${PGDATA}" "${PGDATA}_bootstrap"`, + }, "\n") + + return append([]string{"bash", "-ceu", "--", script, "-", pgdata}, args...) } // DedicatedSnapshotVolumeRestoreCommand returns the command for performing a pgBackRest delta restore @@ -282,7 +356,7 @@ func DedicatedSnapshotVolumeRestoreCommand(pgdata string, args ...string) []stri BACKUP_LABEL=$([[ ! -e "${pgdata}/backup_label" ]] || md5sum "${pgdata}/backup_label") echo "Starting pgBackRest delta restore" -install --directory --mode=0700 "${pgdata}" +install --directory --mode=0750 "${pgdata}" rm -f "${pgdata}/postmaster.pid" bash -xc "pgbackrest restore ${opts}" rm -f "${pgdata}/patroni.dynamic.json" @@ -443,6 +517,69 @@ func populateRepoHostConfigurationMap( } } +func populateCloudRepoConfigurationMap( + serviceName, serviceNamespace, pgdataDir, + fetchKeyCommand, postgresVersion, logPath string, + pgPort int32, pgHosts []string, repos []v1beta1.PGBackRestRepo, + globalConfig map[string]string, +) iniSectionSet { + + global := iniMultiSet{} + stanza := iniMultiSet{} + + for _, repo := range repos { + if repo.Volume != nil { + continue + } + + global.Set(repo.Name+"-path", defaultRepo1Path+repo.Name) + + for option, val := range getExternalRepoConfigs(repo) { + global.Set(option, val) + } + } + + // If we are given a log path, set it in the config. Otherwise, turn off logging to file. + if logPath != "" { + global.Set("log-path", logPath) + } else { + global.Set("log-level-file", "off") + } + + for option, val := range globalConfig { + global.Set(option, val) + } + + // set the configs for all PG hosts + for i, pgHost := range pgHosts { + // TODO(cbandy): pass a FQDN in already. + pgHostFQDN := pgHost + "-0." + + serviceName + "." + serviceNamespace + ".svc." + + naming.KubernetesClusterDomain(context.Background()) + + stanza.Set(fmt.Sprintf("pg%d-host", i+1), pgHostFQDN) + stanza.Set(fmt.Sprintf("pg%d-host-type", i+1), "tls") + stanza.Set(fmt.Sprintf("pg%d-host-ca-file", i+1), certAuthorityAbsolutePath) + stanza.Set(fmt.Sprintf("pg%d-host-cert-file", i+1), certClientAbsolutePath) + stanza.Set(fmt.Sprintf("pg%d-host-key-file", i+1), certClientPrivateKeyAbsolutePath) + + stanza.Set(fmt.Sprintf("pg%d-path", i+1), pgdataDir) + stanza.Set(fmt.Sprintf("pg%d-port", i+1), fmt.Sprint(pgPort)) + stanza.Set(fmt.Sprintf("pg%d-socket-path", i+1), postgres.SocketDirectory) + + if fetchKeyCommand != "" { + stanza.Set("archive-header-check", "n") + stanza.Set("page-header-check", "n") + stanza.Set("pg-version-force", postgresVersion) + } + } + + return iniSectionSet{ + "global": global, + DefaultStanzaName: stanza, + } +} + // getExternalRepoConfigs returns a map containing the configuration settings for an external // pgBackRest repository as defined in the PostgresCluster spec func getExternalRepoConfigs(repo v1beta1.PGBackRestRepo) map[string]string { diff --git a/internal/pgbackrest/config.md b/internal/pgbackrest/config.md index 2101535b3a..f19c9ac1e4 100644 --- a/internal/pgbackrest/config.md +++ b/internal/pgbackrest/config.md @@ -1,22 +1,22 @@ # pgBackRest Configuration Overview -The initial pgBackRest configuration for the Postgres Clusters is designed to stand up a +The initial pgBackRest configuration for the Postgres Clusters is designed to stand up a minimal configuration for use by the various pgBackRest functions needed by the Postgres cluster. These settings are meant to be the minimally required settings, with other settings supported through the use of custom configurations. -During initial cluster creation, four pgBackRest use cases are involved. +During initial cluster creation, four pgBackRest use cases are involved. -These settings are configured in either the [global] or [stanza] sections of the +These settings are configured in either the [global] or [stanza] sections of the pgBackRest configuration based on their designation in the pgBackRest code. For more information on the above, and other settings, please see -https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/config/parse.auto.c + As shown, the settings with the `cfgSectionGlobal` designation are @@ -24,18 +24,17 @@ As shown, the settings with the `cfgSectionGlobal` designation are `log-level-file`: Level for file logging. Set to 'off' when the repo host has no volume. -`repo-path`: Path where backups and archive are stored. +`repo-path`: Path where backups and archive are stored. The repository is where pgBackRest stores backups and archives WAL segments. `repo-host`: Repository host when operating remotely via TLS. - The settings with the `cfgSectionStanza` designation are `pg-host`: PostgreSQL host for operating remotely via TLS. `pg-path`: The path of the PostgreSQL data directory. - This should be the same as the data_directory setting in postgresql.conf. + This should be the same as the data_directory setting in postgresql.conf. `pg-port`: The port that PostgreSQL is running on. @@ -44,14 +43,13 @@ The settings with the `cfgSectionStanza` designation are For more information on these and other configuration settings, please see `https://pgbackrest.org/configuration.html`. -# Configuration Per Function +## Configuration Per Function -Below, each of the four configuration sets is outlined by use case. Please note that certain -settings have acceptable defaults for the cluster's usage (such as for `repo1-type` which +Below, each of the four configuration sets is outlined by use case. Please note that certain +settings have acceptable defaults for the cluster's usage (such as for `repo1-type` which defaults to `posix`), so those settings are not included. - -1. Primary Database Pod +1. Primary Database Pod [global] log-path @@ -86,28 +84,26 @@ log-path [global] log-path - -# Initial pgBackRest Configuration +## Initial pgBackRest Configuration In order to be used by the Postgres cluster, these default configurations are stored in -a configmap. This configmap is named with the following convention `-pgbackrest-config`, +a configmap. This configmap is named with the following convention `-pgbackrest-config`, such that a cluster named 'mycluster' would have a configuration configmap named `mycluster-pgbackrest-config`. -As noted above, there are three distinct default configurations, each of which is referenced +As noted above, there are three distinct default configurations, each of which is referenced by a key value in the configmap's data section. For the primary database pod, the key is `pgbackrest_primary.conf`. For the pgBackRest repo pod, the key is `pgbackrest_repo.conf`. Finally, for the pgBackRest stanza job pod and the initial pgBackRest backup job pod, the key is `pgbackrest_job.conf`. - -For each pod, the relevant configuration file is mounted as a projected volume named + +For each pod, the relevant configuration file is mounted as a projected volume named `pgbackrest-config-vol`. The configuration file will be found in the `/etc/pgbackrest` directory -of the relevant container and is named `pgbackrest.conf`, matching the default pgBackRest location. -For more information, please see +of the relevant container and is named `pgbackrest.conf`, matching the default pgBackRest location. +For more information, please see `https://pgbackrest.org/configuration.html#introduction` - -# Custom Configuration Support +## Custom Configuration Support TODO(tjmoore4): Document custom configuration solution once implemented @@ -116,7 +112,7 @@ flag with the desired pgBackRest command. This should point to the directory pat where the `*.conf` file with the custom configuration is located. This file will be added as a projected volume and must be formatted in the standard -pgBackRest INI convention. Please note that any of the configuration settings listed +pgBackRest INI convention. Please note that any of the configuration settings listed above MUST BE CONFIGURED VIA THE POSTGRESCLUSTER SPEC so as to avoid errors. For more information, please see @@ -140,7 +136,7 @@ command-line or top-to-bottom in INI files. The remaining options must be set exactly once. `pgbackrest` exits non-zero when the option occurs twice on the command-line or twice in a file: -``` +```text ERROR: [031]: option 'io-timeout' cannot be set multiple times ``` diff --git a/internal/pgbackrest/config_test.go b/internal/pgbackrest/config_test.go index b74bf9a4a8..c1b4e0b155 100644 --- a/internal/pgbackrest/config_test.go +++ b/internal/pgbackrest/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,13 +13,12 @@ import ( "testing" "gotest.tools/v3/assert" - "gotest.tools/v3/assert/cmp" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" - "sigs.k8s.io/yaml" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/testing/cmp" "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -34,20 +33,272 @@ func TestCreatePGBackRestConfigMapIntent(t *testing.T) { domain := naming.KubernetesClusterDomain(context.Background()) - t.Run("NoVolumeRepo", func(t *testing.T) { + t.Run("NoRepos", func(t *testing.T) { + // We always create the config for the pgbackrest instance and server cluster := cluster.DeepCopy() cluster.Spec.Backups.PGBackRest.Repos = nil + cluster.UID = "piano" - configmap := CreatePGBackRestConfigMapIntent(cluster, - "", "number", "pod-service-name", "test-ns", + configmap, err := CreatePGBackRestConfigMapIntent(context.Background(), cluster, + "", "number", "pod-service-name", "test-ns", "", []string{"some-instance"}) + assert.NilError(t, err) assert.Equal(t, configmap.Data["config-hash"], "number") - assert.Equal(t, configmap.Data["pgbackrest-server.conf"], "") + assert.Equal(t, configmap.Data["pgbackrest-server.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +tls-server-address = 0.0.0.0 +tls-server-auth = pgbackrest@piano=* +tls-server-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +tls-server-cert-file = /etc/pgbackrest/server/server-tls.crt +tls-server-key-file = /etc/pgbackrest/server/server-tls.key + +[global:server] +log-level-console = detail +log-level-file = off +log-level-stderr = error +log-timestamp = n + `, "\t\n")+"\n") + + assert.Equal(t, configmap.Data["pgbackrest_instance.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +archive-async = y +log-path = /pgdata/pgbackrest/log +spool-path = /pgdata/pgbackrest-spool + +[db] +pg1-path = /pgdata/pg12 +pg1-port = 2345 +pg1-socket-path = /tmp/postgres + `, "\t\n")+"\n") + + assert.Equal(t, configmap.Data["pgbackrest_repo.conf"], "") + assert.Equal(t, configmap.Data["pgbackrest_cloud.conf"], "") + }) + + t.Run("CloudRepoPresentNoVolumeRepo", func(t *testing.T) { + cluster := cluster.DeepCopy() + cluster.UID = "ukulele" + cluster.Spec.Backups.PGBackRest.Global = map[string]string{ + "repo1-test": "something", + } + cluster.Spec.Backups.PGBackRest.Repos = []v1beta1.PGBackRestRepo{ + { + Name: "repo1", + GCS: &v1beta1.RepoGCS{Bucket: "g-bucket"}, + }, + } + + configmap, err := CreatePGBackRestConfigMapIntent(context.Background(), cluster, + "", "anumber", "pod-service-name", "test-ns", "", + []string{"some-instance"}) + assert.NilError(t, err) + + configmapWithCloudLogging, err := CreatePGBackRestConfigMapIntent(context.Background(), cluster, + "", "anumber", "pod-service-name", "test-ns", "/a/log/path", + []string{"some-instance"}) + + assert.NilError(t, err) + assert.DeepEqual(t, configmap.Annotations, map[string]string{}) + assert.DeepEqual(t, configmapWithCloudLogging.Annotations, map[string]string{}) + + assert.DeepEqual(t, configmap.Labels, map[string]string{ + "postgres-operator.crunchydata.com/cluster": "hippo-dance", + "postgres-operator.crunchydata.com/pgbackrest": "", + "postgres-operator.crunchydata.com/pgbackrest-config": "", + }) + assert.DeepEqual(t, configmapWithCloudLogging.Labels, map[string]string{ + "postgres-operator.crunchydata.com/cluster": "hippo-dance", + "postgres-operator.crunchydata.com/pgbackrest": "", + "postgres-operator.crunchydata.com/pgbackrest-config": "", + }) + + assert.Equal(t, configmap.Data["config-hash"], "anumber") + assert.Equal(t, configmapWithCloudLogging.Data["config-hash"], "anumber") + + serverConfigExpectation := strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +tls-server-address = 0.0.0.0 +tls-server-auth = pgbackrest@ukulele=* +tls-server-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +tls-server-cert-file = /etc/pgbackrest/server/server-tls.crt +tls-server-key-file = /etc/pgbackrest/server/server-tls.key + +[global:server] +log-level-console = detail +log-level-file = off +log-level-stderr = error +log-timestamp = n + `, "\t\n") + assert.Equal(t, configmap.Data["pgbackrest-server.conf"], serverConfigExpectation+"\n") + assert.Equal(t, configmapWithCloudLogging.Data["pgbackrest-server.conf"], serverConfigExpectation+"\n") + + instanceConfigExpectation := strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +archive-async = y +log-path = /pgdata/pgbackrest/log +repo1-gcs-bucket = g-bucket +repo1-path = /pgbackrest/repo1 +repo1-test = something +repo1-type = gcs +spool-path = /pgdata/pgbackrest-spool + +[db] +pg1-path = /pgdata/pg12 +pg1-port = 2345 +pg1-socket-path = /tmp/postgres + `, "\t\n") + assert.Equal(t, configmap.Data["pgbackrest_instance.conf"], instanceConfigExpectation+"\n") + assert.Equal(t, configmapWithCloudLogging.Data["pgbackrest_instance.conf"], instanceConfigExpectation+"\n") + + assert.Equal(t, configmap.Data["pgbackrest_cloud.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +log-level-file = off +repo1-gcs-bucket = g-bucket +repo1-path = /pgbackrest/repo1 +repo1-test = something +repo1-type = gcs + +[db] +pg1-host = some-instance-0.pod-service-name.test-ns.svc.`+domain+` +pg1-host-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +pg1-host-cert-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.crt +pg1-host-key-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.key +pg1-host-type = tls +pg1-path = /pgdata/pg12 +pg1-port = 2345 +pg1-socket-path = /tmp/postgres + `, "\t\n")+"\n") + + assert.Equal(t, configmapWithCloudLogging.Data["pgbackrest_cloud.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +log-path = /a/log/path +repo1-gcs-bucket = g-bucket +repo1-path = /pgbackrest/repo1 +repo1-test = something +repo1-type = gcs + +[db] +pg1-host = some-instance-0.pod-service-name.test-ns.svc.`+domain+` +pg1-host-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +pg1-host-cert-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.crt +pg1-host-key-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.key +pg1-host-type = tls +pg1-path = /pgdata/pg12 +pg1-port = 2345 +pg1-socket-path = /tmp/postgres + `, "\t\n")+"\n") + + assert.Equal(t, configmap.Data["pgbackrest_repo.conf"], "") + assert.Equal(t, configmapWithCloudLogging.Data["pgbackrest_repo.conf"], "") + }) + + t.Run("VolumeRepoPresentNoCloudRepo", func(t *testing.T) { + cluster := cluster.DeepCopy() + cluster.UID = "guitar" + cluster.Spec.Backups.PGBackRest.Repos = []v1beta1.PGBackRestRepo{ + { + Name: "repo1", + Volume: &v1beta1.RepoPVC{}, + }, + } + + configmap, err := CreatePGBackRestConfigMapIntent(context.Background(), cluster, + "repo-hostname", "anumber", "pod-service-name", "test-ns", "", + []string{"some-instance"}) + + assert.NilError(t, err) + assert.DeepEqual(t, configmap.Annotations, map[string]string{}) + assert.DeepEqual(t, configmap.Labels, map[string]string{ + "postgres-operator.crunchydata.com/cluster": "hippo-dance", + "postgres-operator.crunchydata.com/pgbackrest": "", + "postgres-operator.crunchydata.com/pgbackrest-config": "", + }) + + assert.Equal(t, configmap.Data["config-hash"], "anumber") + assert.Equal(t, configmap.Data["pgbackrest-server.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +tls-server-address = 0.0.0.0 +tls-server-auth = pgbackrest@guitar=* +tls-server-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +tls-server-cert-file = /etc/pgbackrest/server/server-tls.crt +tls-server-key-file = /etc/pgbackrest/server/server-tls.key + +[global:server] +log-level-console = detail +log-level-file = off +log-level-stderr = error +log-timestamp = n + `, "\t\n")+"\n") + + assert.Equal(t, configmap.Data["pgbackrest_instance.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +archive-async = y +log-path = /pgdata/pgbackrest/log +repo1-host = repo-hostname-0.pod-service-name.test-ns.svc.`+domain+` +repo1-host-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +repo1-host-cert-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.crt +repo1-host-key-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.key +repo1-host-type = tls +repo1-host-user = postgres +repo1-path = /pgbackrest/repo1 +spool-path = /pgdata/pgbackrest-spool + +[db] +pg1-path = /pgdata/pg12 +pg1-port = 2345 +pg1-socket-path = /tmp/postgres + `, "\t\n")+"\n") + + assert.Equal(t, configmap.Data["pgbackrest_repo.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +log-path = /pgbackrest/repo1/log +repo1-path = /pgbackrest/repo1 + +[db] +pg1-host = some-instance-0.pod-service-name.test-ns.svc.`+domain+` +pg1-host-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +pg1-host-cert-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.crt +pg1-host-key-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.key +pg1-host-type = tls +pg1-path = /pgdata/pg12 +pg1-port = 2345 +pg1-socket-path = /tmp/postgres + `, "\t\n")+"\n") + + assert.Equal(t, configmap.Data["pgbackrest_cloud.conf"], "") }) - t.Run("DedicatedRepoHost", func(t *testing.T) { + t.Run("DedicatedRepoHostAndCloudRepos", func(t *testing.T) { cluster := cluster.DeepCopy() + cluster.UID = "bass" cluster.Spec.Backups.PGBackRest.Global = map[string]string{ "repo3-test": "something", } @@ -72,10 +323,11 @@ func TestCreatePGBackRestConfigMapIntent(t *testing.T) { }, } - configmap := CreatePGBackRestConfigMapIntent(cluster, - "repo-hostname", "abcde12345", "pod-service-name", "test-ns", + configmap, err := CreatePGBackRestConfigMapIntent(context.Background(), cluster, + "repo-hostname", "abcde12345", "pod-service-name", "test-ns", "", []string{"some-instance"}) + assert.NilError(t, err) assert.DeepEqual(t, configmap.Annotations, map[string]string{}) assert.DeepEqual(t, configmap.Labels, map[string]string{ "postgres-operator.crunchydata.com/cluster": "hippo-dance", @@ -84,6 +336,25 @@ func TestCreatePGBackRestConfigMapIntent(t *testing.T) { }) assert.Equal(t, configmap.Data["config-hash"], "abcde12345") + + assert.Equal(t, configmap.Data["pgbackrest-server.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +tls-server-address = 0.0.0.0 +tls-server-auth = pgbackrest@bass=* +tls-server-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +tls-server-cert-file = /etc/pgbackrest/server/server-tls.crt +tls-server-key-file = /etc/pgbackrest/server/server-tls.key + +[global:server] +log-level-console = detail +log-level-file = off +log-level-stderr = error +log-timestamp = n + `, "\t\n")+"\n") + assert.Equal(t, configmap.Data["pgbackrest_repo.conf"], strings.Trim(` # Generated by postgres-operator. DO NOT EDIT. # Your changes will not be saved. @@ -146,6 +417,36 @@ spool-path = /pgdata/pgbackrest-spool [db] pg1-path = /pgdata/pg12 pg1-port = 2345 +pg1-socket-path = /tmp/postgres + `, "\t\n")+"\n") + + assert.Equal(t, configmap.Data["pgbackrest_cloud.conf"], strings.Trim(` +# Generated by postgres-operator. DO NOT EDIT. +# Your changes will not be saved. + +[global] +log-level-file = off +repo2-azure-container = a-container +repo2-path = /pgbackrest/repo2 +repo2-type = azure +repo3-gcs-bucket = g-bucket +repo3-path = /pgbackrest/repo3 +repo3-test = something +repo3-type = gcs +repo4-path = /pgbackrest/repo4 +repo4-s3-bucket = s-bucket +repo4-s3-endpoint = endpoint-s +repo4-s3-region = earth +repo4-type = s3 + +[db] +pg1-host = some-instance-0.pod-service-name.test-ns.svc.`+domain+` +pg1-host-ca-file = /etc/pgbackrest/conf.d/~postgres-operator/tls-ca.crt +pg1-host-cert-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.crt +pg1-host-key-file = /etc/pgbackrest/conf.d/~postgres-operator/client-tls.key +pg1-host-type = tls +pg1-path = /pgdata/pg12 +pg1-port = 2345 pg1-socket-path = /tmp/postgres `, "\t\n")+"\n") }) @@ -177,9 +478,10 @@ pg1-socket-path = /tmp/postgres }, } - configmap := CreatePGBackRestConfigMapIntent(cluster, - "any", "any", "any", "any", nil) + configmap, err := CreatePGBackRestConfigMapIntent(context.Background(), cluster, + "any", "any", "any", "any", "any", nil) + assert.NilError(t, err) assert.DeepEqual(t, configmap.Annotations, map[string]string{ "ak1": "cluster-av1", "ak2": "backups-av2", @@ -208,18 +510,19 @@ pg1-socket-path = /tmp/postgres }, } - configmap := CreatePGBackRestConfigMapIntent(cluster, - "", "number", "pod-service-name", "test-ns", + configmap, err := CreatePGBackRestConfigMapIntent(context.Background(), cluster, + "", "number", "pod-service-name", "test-ns", "", []string{"some-instance"}) + assert.NilError(t, err) assert.Assert(t, - strings.Contains(configmap.Data["pgbackrest_instance.conf"], + cmp.Contains(configmap.Data["pgbackrest_instance.conf"], "archive-header-check = n")) assert.Assert(t, - strings.Contains(configmap.Data["pgbackrest_instance.conf"], + cmp.Contains(configmap.Data["pgbackrest_instance.conf"], "page-header-check = n")) assert.Assert(t, - strings.Contains(configmap.Data["pgbackrest_instance.conf"], + cmp.Contains(configmap.Data["pgbackrest_instance.conf"], "pg-version-force")) cluster.Spec.Backups.PGBackRest.Repos = []v1beta1.PGBackRestRepo{ @@ -229,18 +532,19 @@ pg1-socket-path = /tmp/postgres }, } - configmap = CreatePGBackRestConfigMapIntent(cluster, - "repo1", "number", "pod-service-name", "test-ns", + configmap, err = CreatePGBackRestConfigMapIntent(context.Background(), cluster, + "repo1", "number", "pod-service-name", "test-ns", "", []string{"some-instance"}) + assert.NilError(t, err) assert.Assert(t, - strings.Contains(configmap.Data["pgbackrest_repo.conf"], + cmp.Contains(configmap.Data["pgbackrest_repo.conf"], "archive-header-check = n")) assert.Assert(t, - strings.Contains(configmap.Data["pgbackrest_repo.conf"], + cmp.Contains(configmap.Data["pgbackrest_repo.conf"], "page-header-check = n")) assert.Assert(t, - strings.Contains(configmap.Data["pgbackrest_repo.conf"], + cmp.Contains(configmap.Data["pgbackrest_repo.conf"], "pg-version-force")) }) } @@ -288,7 +592,7 @@ func TestMakePGBackrestLogDir(t *testing.T) { for _, c := range podTemplate.Spec.InitContainers { if c.Name == naming.ContainerPGBackRestLogDirInit { // ignore "bash -c", should skip repo with no volume - assert.Equal(t, "mkdir -p /pgbackrest/repo2/log", c.Command[2]) + assert.Equal(t, `mkdir -p '/pgbackrest/repo2/log' && { chmod 0775 '/pgbackrest/repo2/log' || :; }`, c.Command[2]) assert.Equal(t, c.Image, "test-image") assert.Equal(t, c.ImagePullPolicy, corev1.PullAlways) assert.Assert(t, !cmp.DeepEqual(c.SecurityContext, @@ -317,16 +621,14 @@ func TestReloadCommand(t *testing.T) { assert.NilError(t, os.WriteFile(file, []byte(command[3]), 0o600)) // Expect shellcheck to be happy. - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) } func TestReloadCommandPrettyYAML(t *testing.T) { - b, err := yaml.Marshal(reloadCommand("any")) - assert.NilError(t, err) - assert.Assert(t, strings.Contains(string(b), "\n- |"), - "expected literal block scalar, got:\n%s", b) + assert.Assert(t, cmp.MarshalContains(reloadCommand("any"), "\n- |"), + "expected literal block scalar") } func TestRestoreCommand(t *testing.T) { @@ -345,25 +647,27 @@ func TestRestoreCommand(t *testing.T) { file := filepath.Join(dir, "script.bash") assert.NilError(t, os.WriteFile(file, []byte(command[3]), 0o600)) - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) } func TestRestoreCommandPrettyYAML(t *testing.T) { - b, err := yaml.Marshal(RestoreCommand("/dir", "try", "", nil, "--options")) - - assert.NilError(t, err) - assert.Assert(t, strings.Contains(string(b), "\n- |"), - "expected literal block scalar, got:\n%s", b) + assert.Assert(t, + cmp.MarshalContains( + RestoreCommand("/dir", "try", "", nil, "--options"), + "\n- |", + ), + "expected literal block scalar") } func TestRestoreCommandTDE(t *testing.T) { - b, err := yaml.Marshal(RestoreCommand("/dir", "try", "echo testValue", nil, "--options")) - - assert.NilError(t, err) - assert.Assert(t, strings.Contains(string(b), "encryption_key_command = 'echo testValue'"), - "expected encryption_key_command setting, got:\n%s", b) + assert.Assert(t, + cmp.MarshalContains( + RestoreCommand("/dir", "try", "echo testValue", nil, "--options"), + "encryption_key_command = 'echo testValue'", + ), + "expected encryption_key_command setting") } func TestDedicatedSnapshotVolumeRestoreCommand(t *testing.T) { @@ -382,17 +686,18 @@ func TestDedicatedSnapshotVolumeRestoreCommand(t *testing.T) { file := filepath.Join(dir, "script.bash") assert.NilError(t, os.WriteFile(file, []byte(command[3]), 0o600)) - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) } func TestDedicatedSnapshotVolumeRestoreCommandPrettyYAML(t *testing.T) { - b, err := yaml.Marshal(DedicatedSnapshotVolumeRestoreCommand("/dir", "--options")) - - assert.NilError(t, err) - assert.Assert(t, strings.Contains(string(b), "\n- |"), - "expected literal block scalar, got:\n%s", b) + assert.Assert(t, + cmp.MarshalContains( + DedicatedSnapshotVolumeRestoreCommand("/dir", "--options"), + "\n- |", + ), + "expected literal block scalar") } func TestServerConfig(t *testing.T) { diff --git a/internal/pgbackrest/iana.go b/internal/pgbackrest/iana.go index c6e2f71e6c..70d332cb73 100644 --- a/internal/pgbackrest/iana.go +++ b/internal/pgbackrest/iana.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/options.go b/internal/pgbackrest/options.go index 2439901e47..a768f7c37d 100644 --- a/internal/pgbackrest/options.go +++ b/internal/pgbackrest/options.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/options_test.go b/internal/pgbackrest/options_test.go index 374737ec7f..3652dd94bd 100644 --- a/internal/pgbackrest/options_test.go +++ b/internal/pgbackrest/options_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/pgbackrest.go b/internal/pgbackrest/pgbackrest.go index 21124b9744..e14fbf5fbc 100644 --- a/internal/pgbackrest/pgbackrest.go +++ b/internal/pgbackrest/pgbackrest.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/pgbackrest_test.go b/internal/pgbackrest/pgbackrest_test.go index 33c97913cf..f3f870f89b 100644 --- a/internal/pgbackrest/pgbackrest_test.go +++ b/internal/pgbackrest/pgbackrest_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,12 +13,10 @@ import ( "testing" "gotest.tools/v3/assert" - "k8s.io/apimachinery/pkg/api/resource" - corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "github.com/crunchydata/postgres-operator/internal/testing/require" - "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -61,7 +59,7 @@ fi Repos: []v1beta1.PGBackRestRepo{{ Name: "repo1", Volume: &v1beta1.RepoPVC{ - VolumeClaimSpec: corev1.PersistentVolumeClaimSpec{ + VolumeClaimSpec: v1beta1.VolumeClaimSpec{ AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany}, Resources: corev1.VolumeResourceRequirements{ Requests: map[corev1.ResourceName]resource.Quantity{ @@ -94,7 +92,7 @@ fi assert.NilError(t, os.WriteFile(file, []byte(shellCheckScript), 0o600)) // Expect shellcheck to be happy. - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) } diff --git a/internal/pgbackrest/postgres.go b/internal/pgbackrest/postgres.go index ab5c71868c..3d07e81ad3 100644 --- a/internal/pgbackrest/postgres.go +++ b/internal/pgbackrest/postgres.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -11,8 +11,8 @@ import ( "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) -// PostgreSQL populates outParameters with any settings needed to run pgBackRest. -func PostgreSQL( +// PostgreSQLParameters populates outParameters with any settings needed to run pgBackRest. +func PostgreSQLParameters( inCluster *v1beta1.PostgresCluster, outParameters *postgres.Parameters, backupsEnabled bool, diff --git a/internal/pgbackrest/postgres_test.go b/internal/pgbackrest/postgres_test.go index b87b35631a..d7d272905e 100644 --- a/internal/pgbackrest/postgres_test.go +++ b/internal/pgbackrest/postgres_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -17,7 +17,7 @@ func TestPostgreSQLParameters(t *testing.T) { cluster := new(v1beta1.PostgresCluster) parameters := new(postgres.Parameters) - PostgreSQL(cluster, parameters, true) + PostgreSQLParameters(cluster, parameters, true) assert.DeepEqual(t, parameters.Mandatory.AsMap(), map[string]string{ "archive_mode": "on", "archive_command": `pgbackrest --stanza=db archive-push "%p"`, @@ -28,7 +28,7 @@ func TestPostgreSQLParameters(t *testing.T) { "archive_timeout": "60s", }) - PostgreSQL(cluster, parameters, false) + PostgreSQLParameters(cluster, parameters, false) assert.DeepEqual(t, parameters.Mandatory.AsMap(), map[string]string{ "archive_mode": "on", "archive_command": "true", @@ -40,7 +40,7 @@ func TestPostgreSQLParameters(t *testing.T) { RepoName: "repo99", } - PostgreSQL(cluster, parameters, true) + PostgreSQLParameters(cluster, parameters, true) assert.DeepEqual(t, parameters.Mandatory.AsMap(), map[string]string{ "archive_mode": "on", "archive_command": `pgbackrest --stanza=db archive-push "%p"`, diff --git a/internal/pgbackrest/rbac.go b/internal/pgbackrest/rbac.go index 950f10ef8b..b4e4ea7f93 100644 --- a/internal/pgbackrest/rbac.go +++ b/internal/pgbackrest/rbac.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/rbac_test.go b/internal/pgbackrest/rbac_test.go index a620276f64..1db638e43e 100644 --- a/internal/pgbackrest/rbac_test.go +++ b/internal/pgbackrest/rbac_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbackrest/reconcile.go b/internal/pgbackrest/reconcile.go index d22bccc3c0..426e1312f6 100644 --- a/internal/pgbackrest/reconcile.go +++ b/internal/pgbackrest/reconcile.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -103,17 +103,17 @@ func AddConfigToInstancePod( configmap.ConfigMap.Items = []corev1.KeyToPath{ {Key: CMInstanceKey, Path: CMInstanceKey}, {Key: ConfigHashKey, Path: ConfigHashKey}, + {Key: serverConfigMapKey, Path: serverConfigProjectionPath}, } + // As the cluster transitions from having a repository host to having none, + // PostgreSQL instances that have not rolled out expect to mount client + // certificates. Specify those files are optional so the configuration + // volumes stay valid and Kubernetes propagates their contents to those pods. secret := corev1.VolumeProjection{Secret: &corev1.SecretProjection{}} secret.Secret.Name = naming.PGBackRestSecret(cluster).Name - - configmap.ConfigMap.Items = append( - configmap.ConfigMap.Items, corev1.KeyToPath{ - Key: serverConfigMapKey, - Path: serverConfigProjectionPath, - }) secret.Secret.Items = append(secret.Secret.Items, clientCertificates()...) + secret.Secret.Optional = initialize.Bool(true) // Start with a copy of projections specified in the cluster. Items later in // the list take precedence over earlier items (that is, last write wins). @@ -130,7 +130,7 @@ func AddConfigToInstancePod( addConfigVolumeAndMounts(pod, sources) } -// AddConfigToRepoPod adds and mounts the pgBackRest configuration volume for +// AddConfigToRepoPod adds and mounts the pgBackRest configuration volumes for // the dedicated repository host of cluster to pod. The pgBackRest containers // must already be in pod. func AddConfigToRepoPod( @@ -157,6 +157,33 @@ func AddConfigToRepoPod( addConfigVolumeAndMounts(pod, append(sources, configmap, secret)) } +// AddConfigToCloudBackupJob adds and mounts the pgBackRest configuration volumes +// to the backup job for creating a backup to a cloud repo. +func AddConfigToCloudBackupJob( + cluster *v1beta1.PostgresCluster, podTemplateSpec *corev1.PodTemplateSpec, +) { + configmap := corev1.VolumeProjection{ConfigMap: &corev1.ConfigMapProjection{}} + configmap.ConfigMap.Name = naming.PGBackRestConfig(cluster).Name + configmap.ConfigMap.Items = []corev1.KeyToPath{ + {Key: CMCloudRepoKey, Path: CMCloudRepoKey}, + } + + secret := corev1.VolumeProjection{Secret: &corev1.SecretProjection{}} + secret.Secret.Name = naming.PGBackRestSecret(cluster).Name + secret.Secret.Items = append(secret.Secret.Items, clientCertificates()...) + + // Start with a copy of projections specified in the cluster. Items later in + // the list take precedence over earlier items (that is, last write wins). + // - https://kubernetes.io/docs/concepts/storage/volumes/#projected + sources := append([]corev1.VolumeProjection{}, + cluster.Spec.Backups.PGBackRest.Configuration...) + + addConfigVolumeAndMounts(&podTemplateSpec.Spec, append(sources, configmap, secret)) + + // Add tmp directory for pgbackrest lock files + AddTMPEmptyDir(podTemplateSpec) +} + // AddConfigToRestorePod adds and mounts the pgBackRest configuration volume // for the restore job of cluster to pod. The pgBackRest containers must // already be in pod. @@ -213,8 +240,8 @@ func AddConfigToRestorePod( } // mount any provided configuration files to the restore Job Pod - if len(cluster.Spec.Config.Files) != 0 { - additionalConfigVolumeMount := postgres.AdditionalConfigVolumeMount() + if cluster.Spec.Config != nil && len(cluster.Spec.Config.Files) != 0 { + additionalConfigVolumeMount := postgres.ConfigVolumeMount() additionalConfigVolume := corev1.Volume{Name: additionalConfigVolumeMount.Name} additionalConfigVolume.Projected = &corev1.ProjectedVolumeSource{ Sources: append(sources, cluster.Spec.Config.Files...), @@ -508,38 +535,36 @@ func Secret(ctx context.Context, var err error // Save the CA and generate a TLS client certificate for the entire cluster. - if inRepoHost != nil { - initialize.Map(&outSecret.Data) - - // The server verifies its "tls-server-auth" option contains the common - // name (CN) of the certificate presented by a client. The entire - // cluster uses a single client certificate so the "tls-server-auth" - // option can stay the same when PostgreSQL instances and repository - // hosts are added or removed. - leaf := &pki.LeafCertificate{} - commonName := clientCommonName(inCluster) - dnsNames := []string{commonName} + initialize.Map(&outSecret.Data) - if err == nil { - // Unmarshal and validate the stored leaf. These first errors can - // be ignored because they result in an invalid leaf which is then - // correctly regenerated. - _ = leaf.Certificate.UnmarshalText(inSecret.Data[certClientSecretKey]) - _ = leaf.PrivateKey.UnmarshalText(inSecret.Data[certClientPrivateKeySecretKey]) + // The server verifies its "tls-server-auth" option contains the common + // name (CN) of the certificate presented by a client. The entire + // cluster uses a single client certificate so the "tls-server-auth" + // option can stay the same when PostgreSQL instances and repository + // hosts are added or removed. + leaf := &pki.LeafCertificate{} + commonName := clientCommonName(inCluster) + dnsNames := []string{commonName} - leaf, err = inRoot.RegenerateLeafWhenNecessary(leaf, commonName, dnsNames) - err = errors.WithStack(err) - } + if err == nil { + // Unmarshal and validate the stored leaf. These first errors can + // be ignored because they result in an invalid leaf which is then + // correctly regenerated. + _ = leaf.Certificate.UnmarshalText(inSecret.Data[certClientSecretKey]) + _ = leaf.PrivateKey.UnmarshalText(inSecret.Data[certClientPrivateKeySecretKey]) + + leaf, err = inRoot.RegenerateLeafWhenNecessary(leaf, commonName, dnsNames) + err = errors.WithStack(err) + } - if err == nil { - outSecret.Data[certAuthoritySecretKey], err = certFile(inRoot.Certificate) - } - if err == nil { - outSecret.Data[certClientPrivateKeySecretKey], err = certFile(leaf.PrivateKey) - } - if err == nil { - outSecret.Data[certClientSecretKey], err = certFile(leaf.Certificate) - } + if err == nil { + outSecret.Data[certAuthoritySecretKey], err = certFile(inRoot.Certificate) + } + if err == nil { + outSecret.Data[certClientPrivateKeySecretKey], err = certFile(leaf.PrivateKey) + } + if err == nil { + outSecret.Data[certClientSecretKey], err = certFile(leaf.Certificate) } // Generate a TLS server certificate for each repository host. diff --git a/internal/pgbackrest/reconcile_test.go b/internal/pgbackrest/reconcile_test.go index 4957d58f7b..fbd146475c 100644 --- a/internal/pgbackrest/reconcile_test.go +++ b/internal/pgbackrest/reconcile_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -128,7 +128,7 @@ func TestAddRepoVolumesToPod(t *testing.T) { for _, r := range tc.repos { var foundVolume bool for _, v := range template.Spec.Volumes { - if v.Name == r.Name && v.VolumeSource.PersistentVolumeClaim.ClaimName == + if v.Name == r.Name && v.PersistentVolumeClaim.ClaimName == naming.PGBackRestRepoVolume(postgresCluster, r.Name).Name { foundVolume = true break @@ -244,6 +244,7 @@ func TestAddConfigToInstancePod(t *testing.T) { mode: 384 path: ~postgres-operator/client-tls.key name: hippo-pgbackrest + optional: true `)) }) @@ -279,6 +280,7 @@ func TestAddConfigToInstancePod(t *testing.T) { mode: 384 path: ~postgres-operator/client-tls.key name: hippo-pgbackrest + optional: true `)) }) @@ -319,6 +321,7 @@ func TestAddConfigToInstancePod(t *testing.T) { mode: 384 path: ~postgres-operator/client-tls.key name: hippo-pgbackrest + optional: true `)) }) } @@ -396,6 +399,84 @@ func TestAddConfigToRepoPod(t *testing.T) { }) } +func TestAddConfigToCloudBackupJob(t *testing.T) { + cluster := v1beta1.PostgresCluster{} + cluster.Name = "hippo" + cluster.Default() + + podTemplate := corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "other"}, + {Name: "pgbackrest"}, + }, + }, + } + + alwaysExpect := func(t testing.TB, result *corev1.PodSpec) { + // Only Containers and Volumes fields have changed. + assert.DeepEqual(t, podTemplate.Spec, *result, cmpopts.IgnoreFields(podTemplate.Spec, "Containers", "Volumes")) + + // Only pgBackRest container has config mount, but tmp dir is mounted to all containers + assert.Assert(t, cmp.MarshalMatches(result.Containers, ` +- name: other + resources: {} + volumeMounts: + - mountPath: /tmp + name: tmp +- name: pgbackrest + resources: {} + volumeMounts: + - mountPath: /etc/pgbackrest/conf.d + name: pgbackrest-config + readOnly: true + - mountPath: /tmp + name: tmp + `)) + } + + t.Run("CustomProjections", func(t *testing.T) { + custom := corev1.ConfigMapProjection{} + custom.Name = "custom-configmap" + + cluster := cluster.DeepCopy() + cluster.Spec.Backups.PGBackRest.Configuration = []corev1.VolumeProjection{ + {ConfigMap: &custom}, + } + + out := podTemplate.DeepCopy() + AddConfigToCloudBackupJob(cluster, out) + alwaysExpect(t, &out.Spec) + + // Cloud backup configuration files and client certificates + // after custom projections. + assert.Assert(t, cmp.MarshalMatches(out.Spec.Volumes, ` +- name: pgbackrest-config + projected: + sources: + - configMap: + name: custom-configmap + - configMap: + items: + - key: pgbackrest_cloud.conf + path: pgbackrest_cloud.conf + name: hippo-pgbackrest-config + - secret: + items: + - key: pgbackrest.ca-roots + path: ~postgres-operator/tls-ca.crt + - key: pgbackrest-client.crt + path: ~postgres-operator/client-tls.crt + - key: pgbackrest-client.key + mode: 384 + path: ~postgres-operator/client-tls.key + name: hippo-pgbackrest +- emptyDir: + sizeLimit: 16Mi + name: tmp`)) + }) +} + func TestAddConfigToRestorePod(t *testing.T) { cluster := v1beta1.PostgresCluster{} cluster.Name = "source" @@ -522,8 +603,10 @@ func TestAddConfigToRestorePod(t *testing.T) { custom.Name = "custom-configmap-files" cluster := cluster.DeepCopy() - cluster.Spec.Config.Files = []corev1.VolumeProjection{ - {ConfigMap: &custom}, + cluster.Spec.Config = &v1beta1.PostgresConfigSpec{ + Files: []corev1.VolumeProjection{ + {ConfigMap: &custom}, + }, } sourceCluster := cluster.DeepCopy() @@ -1025,10 +1108,13 @@ func TestSecret(t *testing.T) { assert.NilError(t, err) t.Run("NoRepoHost", func(t *testing.T) { - // Nothing happens when there is no repository host. - constant := intent.DeepCopy() + // We always add the pgbackrest server certs assert.NilError(t, Secret(ctx, cluster, nil, root, existing, intent)) - assert.DeepEqual(t, constant, intent) + assert.Assert(t, len(intent.Data["pgbackrest-client.crt"]) > 0) + assert.Assert(t, len(intent.Data["pgbackrest-client.key"]) > 0) + assert.Assert(t, len(intent.Data["pgbackrest.ca-roots"]) > 0) + assert.Assert(t, len(intent.Data["pgbackrest-repo-host.crt"]) == 0) + assert.Assert(t, len(intent.Data["pgbackrest-repo-host.key"]) == 0) }) host := new(appsv1.StatefulSet) diff --git a/internal/pgbackrest/restore.md b/internal/pgbackrest/restore.md index 8828576921..95257990d6 100644 --- a/internal/pgbackrest/restore.md +++ b/internal/pgbackrest/restore.md @@ -1,5 +1,5 @@ diff --git a/internal/pgbackrest/tls-server.md b/internal/pgbackrest/tls-server.md index b572cc1ea4..56af386d5b 100644 --- a/internal/pgbackrest/tls-server.md +++ b/internal/pgbackrest/tls-server.md @@ -1,5 +1,5 @@ @@ -12,10 +12,8 @@ on different pods: - [dedicated repository host](https://pgbackrest.org/user-guide.html#repo-host) - [backup from standby](https://pgbackrest.org/user-guide.html#standby-backup) -When a PostgresCluster is configured to store backups on a PVC, the dedicated -repository host is used to make that PVC available to all PostgreSQL instances -in the cluster. Regardless of whether the repo host has a defined PVC, it -functions as the server for the pgBackRest clients that run on the Instances. +When a PostgresCluster is configured to store backups on a PVC, we start a dedicated +repository host to make that PVC available to all PostgreSQL instances in the cluster. The repository host runs a `pgbackrest` server that is secured through TLS and [certificates][]. When performing backups, it connects to `pgbackrest` servers @@ -26,32 +24,30 @@ to the repository host to [send and receive WAL files][archiving]. [archiving]: https://www.postgresql.org/docs/current/continuous-archiving.html [certificates]: certificates.md - The `pgbackrest` command acts as a TLS client and connects to a pgBackRest TLS server when `pg-host-type=tls` and/or `repo-host-type=tls`. The default for these is `ssh`: -- https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/config/parse.auto.c#L3771 -- https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/config/parse.auto.c#L6137 - +- +- The pgBackRest TLS server is configured through the `tls-server-*` [options](config.md). In pgBackRest 2.38, changing any of these options or changing certificate contents requires a reload of the server, as shown in the "Setup TLS Server" section of the documentation, with the command configured as -``` +```text ExecReload=kill -HUP $MAINPID ``` -- https://pgbackrest.org/user-guide-rhel.html#repo-host/setup-tls +- - `tls-server-address`, `tls-server-port`
The network address and port on which to listen. pgBackRest 2.38 listens on the *first* address returned by `getaddrinfo()`. There is no way to listen on all interfaces. - - https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/common/io/socket/server.c#L172 - - https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/common/io/socket/common.c#L87 + - + - - `tls-server-cert-file`, `tls-server-key-file`
The [certificate chain][certificates] and private key pair used to encrypt connections. @@ -65,12 +61,11 @@ ExecReload=kill -HUP $MAINPID to interact with. [Required](https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/config/parse.auto.c#L8751). - In pgBackRest 2.38, as mentioned above, sending SIGHUP causes a configuration reload. -- https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/command/server/server.c#L178 +- -``` +```text P00 DETAIL: configuration reload begin P00 INFO: server command begin 2.38... P00 DETAIL: configuration reload end @@ -78,20 +73,18 @@ P00 DETAIL: configuration reload end Sending SIGINT to the TLS server causes it to exit with code 63, TermError. -- https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/common/exit.c#L73-L75 -- https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/common/exit.c#L62 -- https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/common/error.auto.c#L48 +- +- +- - -``` +```text P00 INFO: server command end: terminated on signal [SIGINT] ``` Sending SIGTERM exits the signal loop and lead to the command termination. -- https://github.com/pgbackrest/pgbackrest/blob/release/2.38/src/command/server/server.c#L194 +- - -``` +```text P00 INFO: server command end: completed successfully ``` diff --git a/internal/pgbackrest/util.go b/internal/pgbackrest/util.go index 4fc2266c56..cd5fd11261 100644 --- a/internal/pgbackrest/util.go +++ b/internal/pgbackrest/util.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -10,16 +10,21 @@ import ( "io" "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/apimachinery/pkg/util/rand" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) +// TODO: Provide explanation for this specific size. Should a tmp dir ever be smaller or larger? +var tmpDirSizeLimit = resource.MustParse("16Mi") + // maxPGBackrestRepos is the maximum number of repositories that can be configured according to the // multi-repository solution implemented within pgBackRest const maxPGBackrestRepos = 4 -// RepoHostVolumeDefined determines whether not at least one pgBackRest dedicated +// RepoHostVolumeDefined determines whether or not at least one pgBackRest dedicated // repository host volume has been defined in the PostgresCluster manifest. func RepoHostVolumeDefined(postgresCluster *v1beta1.PostgresCluster) bool { for _, repo := range postgresCluster.Spec.Backups.PGBackRest.Repos { @@ -30,6 +35,17 @@ func RepoHostVolumeDefined(postgresCluster *v1beta1.PostgresCluster) bool { return false } +// CloudRepoDefined determines whether or not at least one pgBackRest cloud-based +// repository has been defined in the PostgresCluster manifest. +func CloudRepoDefined(postgresCluster *v1beta1.PostgresCluster) bool { + for _, repo := range postgresCluster.Spec.Backups.PGBackRest.Repos { + if repo.Volume == nil { + return true + } + } + return false +} + // CalculateConfigHashes calculates hashes for any external pgBackRest repository configuration // present in the PostgresCluster spec (e.g. configuration for Azure, GCR and/or S3 repositories). // Additionally it returns a hash of the hashes for each external repository. @@ -100,3 +116,39 @@ func safeHash32(content func(w io.Writer) error) (string, error) { } return rand.SafeEncodeString(fmt.Sprint(hash.Sum32())), nil } + +// AddTMPEmptyDir adds a "tmp" EmptyDir volume to the provided Pod template, while then also adding a +// volume mount at /tmp for all containers defined within the Pod template +// The '/tmp' directory is currently utilized for the following: +// - As the pgBackRest lock directory (this is the default lock location for pgBackRest) +// - The location where the replication client certificates can be loaded with the proper +// permissions set +// +// This function was copied from the postgrescluster package. +func AddTMPEmptyDir(template *corev1.PodTemplateSpec) { + + template.Spec.Volumes = append(template.Spec.Volumes, corev1.Volume{ + Name: "tmp", + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{ + SizeLimit: &tmpDirSizeLimit, + }, + }, + }) + + for i := range template.Spec.Containers { + template.Spec.Containers[i].VolumeMounts = append(template.Spec.Containers[i].VolumeMounts, + corev1.VolumeMount{ + Name: "tmp", + MountPath: "/tmp", + }) + } + + for i := range template.Spec.InitContainers { + template.Spec.InitContainers[i].VolumeMounts = append(template.Spec.InitContainers[i].VolumeMounts, + corev1.VolumeMount{ + Name: "tmp", + MountPath: "/tmp", + }) + } +} diff --git a/internal/pgbackrest/util_test.go b/internal/pgbackrest/util_test.go index eb0f4dec29..d2fd93455c 100644 --- a/internal/pgbackrest/util_test.go +++ b/internal/pgbackrest/util_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,7 +6,7 @@ package pgbackrest import ( "io" - "math/rand" + "math/rand/v2" "strconv" "testing" @@ -80,7 +80,7 @@ func TestCalculateConfigHashes(t *testing.T) { assert.Equal(t, preCalculatedRepo3S3Hash, configHashMap["repo3"]) // call CalculateConfigHashes multiple times to ensure consistent results - for i := 0; i < 10; i++ { + for range 10 { hashMap, hash, err := CalculateConfigHashes(postgresCluster) assert.NilError(t, err) assert.Equal(t, configHash, hash) @@ -92,7 +92,7 @@ func TestCalculateConfigHashes(t *testing.T) { // shuffle the repo slice in order to ensure the same result is returned regardless of the // order of the repos slice shuffleCluster := postgresCluster.DeepCopy() - for i := 0; i < 10; i++ { + for range 10 { repos := shuffleCluster.Spec.Backups.PGBackRest.Repos rand.Shuffle(len(repos), func(i, j int) { repos[i], repos[j] = repos[j], repos[i] @@ -103,7 +103,7 @@ func TestCalculateConfigHashes(t *testing.T) { } // now modify some values in each repo and confirm we see a different result - for i := 0; i < 3; i++ { + for i := range 3 { modCluster := postgresCluster.DeepCopy() switch i { case 0: diff --git a/internal/pgbouncer/certificates.go b/internal/pgbouncer/certificates.go index 31f91c503a..c41169f7f8 100644 --- a/internal/pgbouncer/certificates.go +++ b/internal/pgbouncer/certificates.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbouncer/certificates_test.go b/internal/pgbouncer/certificates_test.go index 5955c3de9c..b220b5f922 100644 --- a/internal/pgbouncer/certificates_test.go +++ b/internal/pgbouncer/certificates_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgbouncer/config.go b/internal/pgbouncer/config.go index a203144817..1c08e94803 100644 --- a/internal/pgbouncer/config.go +++ b/internal/pgbouncer/config.go @@ -1,16 +1,19 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package pgbouncer import ( + "context" "fmt" + "maps" "sort" "strings" corev1 "k8s.io/api/core/v1" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -69,12 +72,12 @@ func authFileContents(password string) []byte { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } - user1 := quote(postgresqlUser) + " " + quote(password) + "\n" + user1 := quote(PostgresqlUser) + " " + quote(password) + "\n" return []byte(user1) } -func clusterINI(cluster *v1beta1.PostgresCluster) string { +func clusterINI(ctx context.Context, cluster *v1beta1.PostgresCluster) string { var ( pgBouncerPort = *cluster.Spec.Proxy.PGBouncer.Port postgresPort = *cluster.Spec.Port @@ -97,7 +100,7 @@ func clusterINI(cluster *v1beta1.PostgresCluster) string { // "auth_user" requires a password, PgBouncer reads it from "auth_file". "auth_file": authFileAbsolutePath, "auth_query": "SELECT username, password from pgbouncer.get_auth($1)", - "auth_user": postgresqlUser, + "auth_user": PostgresqlUser, // TODO(cbandy): Use an HBA file to control authentication of PgBouncer // accounts; e.g. "admin_users" below. @@ -124,11 +127,20 @@ func clusterINI(cluster *v1beta1.PostgresCluster) string { "unix_socket_dir": "", } - // Override the above with any specified settings. - for k, v := range cluster.Spec.Proxy.PGBouncer.Config.Global { - global[k] = v + // If OpenTelemetryLogs feature is enabled, enable logging to file + if collector.OpenTelemetryLogsEnabled(ctx, cluster) { + global["logfile"] = naming.PGBouncerLogPath + "/pgbouncer.log" + } + + // When OTel metrics are enabled, allow pgBouncer's postgres user + // to run read-only console queries on pgBouncer's virtual db + if collector.OpenTelemetryMetricsEnabled(ctx, cluster) { + global["stats_users"] = PostgresqlUser } + // Override the above with any specified settings. + maps.Copy(global, cluster.Spec.Proxy.PGBouncer.Config.Global) + // Prevent the user from bypassing the main configuration file. global["conffile"] = iniFileAbsolutePath diff --git a/internal/pgbouncer/config.md b/internal/pgbouncer/config.md index abfec12518..7b9f3eeefa 100644 --- a/internal/pgbouncer/config.md +++ b/internal/pgbouncer/config.md @@ -1,5 +1,5 @@ diff --git a/internal/pgbouncer/config_test.go b/internal/pgbouncer/config_test.go index 7a96da571c..97ba017ef4 100644 --- a/internal/pgbouncer/config_test.go +++ b/internal/pgbouncer/config_test.go @@ -1,10 +1,11 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package pgbouncer import ( + "context" "os" "os/exec" "path/filepath" @@ -40,6 +41,7 @@ func TestAuthFileContents(t *testing.T) { } func TestClusterINI(t *testing.T) { + ctx := context.Background() t.Parallel() cluster := new(v1beta1.PostgresCluster) @@ -54,7 +56,7 @@ func TestClusterINI(t *testing.T) { *cluster.Spec.Proxy.PGBouncer.Port = 8888 t.Run("Default", func(t *testing.T) { - assert.Equal(t, clusterINI(cluster), strings.Trim(` + assert.Equal(t, clusterINI(ctx, cluster), strings.Trim(` # Generated by postgres-operator. DO NOT EDIT. # Your changes will not be saved. @@ -94,7 +96,7 @@ unix_socket_dir = "app": "mode=rad", } - assert.Equal(t, clusterINI(cluster), strings.Trim(` + assert.Equal(t, clusterINI(ctx, cluster), strings.Trim(` # Generated by postgres-operator. DO NOT EDIT. # Your changes will not be saved. @@ -127,7 +129,7 @@ app = mode=rad // The "conffile" setting cannot be changed. cluster.Spec.Proxy.PGBouncer.Config.Global["conffile"] = "too-far" - assert.Assert(t, !strings.Contains(clusterINI(cluster), "too-far")) + assert.Assert(t, !strings.Contains(clusterINI(ctx, cluster), "too-far")) }) } @@ -214,7 +216,7 @@ func TestReloadCommand(t *testing.T) { assert.NilError(t, os.WriteFile(file, []byte(command[3]), 0o600)) // Expect shellcheck to be happy. - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) } diff --git a/internal/pgbouncer/postgres.go b/internal/pgbouncer/postgres.go index cbc2e29916..2d0b675067 100644 --- a/internal/pgbouncer/postgres.go +++ b/internal/pgbouncer/postgres.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -12,8 +12,6 @@ import ( "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/postgres" - "github.com/crunchydata/postgres-operator/internal/postgres/password" - "github.com/crunchydata/postgres-operator/internal/util" ) const ( @@ -23,7 +21,7 @@ const ( // to also be related to the "auth_user". // - https://github.com/pgbouncer/pgbouncer/issues/568 // - https://github.com/pgbouncer/pgbouncer/issues/302#issuecomment-815097248 - postgresqlUser = "_crunchypgbouncer" + PostgresqlUser = "_crunchypgbouncer" ) // sqlAuthenticationQuery returns the SECURITY DEFINER function that allows @@ -41,14 +39,14 @@ func sqlAuthenticationQuery(sqlFunctionName string) string { // No replicators. `NOT pg_authid.rolreplication`, // Not the PgBouncer role itself. - `pg_authid.rolname <> ` + util.SQLQuoteLiteral(postgresqlUser), + `pg_authid.rolname <> ` + postgres.QuoteLiteral(PostgresqlUser), // Those without a password expiration or an expiration in the future. `(pg_authid.rolvaliduntil IS NULL OR pg_authid.rolvaliduntil >= CURRENT_TIMESTAMP)`, }, "\n AND ") return strings.TrimSpace(` CREATE OR REPLACE FUNCTION ` + sqlFunctionName + `(username TEXT) -RETURNS TABLE(username TEXT, password TEXT) AS ` + util.SQLQuoteLiteral(` +RETURNS TABLE(username TEXT, password TEXT) AS ` + postgres.QuoteLiteral(` SELECT rolname::TEXT, rolpassword::TEXT FROM pg_catalog.pg_authid WHERE pg_authid.rolname = $1 @@ -68,6 +66,10 @@ func DisableInPostgreSQL(ctx context.Context, exec postgres.Executor) error { // - https://www.postgresql.org/docs/current/runtime-config-client.html `SET client_min_messages = WARNING;`, + // Do not wait for changes to be replicated. [Since PostgreSQL v9.1] + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + `SET synchronous_commit = LOCAL;`, + // Drop the following objects in a transaction. `BEGIN;`, @@ -89,7 +91,7 @@ SELECT pg_catalog.format('DROP OWNED BY %I CASCADE', :'username') `COMMIT;`, }, "\n"), map[string]string{ - "username": postgresqlUser, + "username": PostgresqlUser, "namespace": postgresqlSchema, "ON_ERROR_STOP": "on", // Abort when any one statement fails. @@ -102,9 +104,9 @@ SELECT pg_catalog.format('DROP OWNED BY %I CASCADE', :'username') // Remove the PgBouncer user now that the objects and other privileges are gone. stdout, stderr, err = exec.ExecInDatabasesFromQuery(ctx, `SELECT pg_catalog.current_database()`, - `SET client_min_messages = WARNING; DROP ROLE IF EXISTS :"username";`, + `SET client_min_messages = WARNING; SET synchronous_commit = LOCAL; DROP ROLE IF EXISTS :"username";`, map[string]string{ - "username": postgresqlUser, + "username": PostgresqlUser, "ON_ERROR_STOP": "on", // Abort when any one statement fails. "QUIET": "on", // Do not print successful statements to stdout. @@ -130,6 +132,10 @@ func EnableInPostgreSQL( // - https://www.postgresql.org/docs/current/runtime-config-client.html `SET client_min_messages = WARNING;`, + // Do not wait for changes to be replicated. [Since PostgreSQL v9.1] + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + `SET synchronous_commit = LOCAL;`, + // Create the following objects in a transaction so that permissions // are correct before any other session sees them. // - https://www.postgresql.org/docs/current/ddl-priv.html @@ -175,14 +181,14 @@ REVOKE ALL PRIVILEGES // - https://www.postgresql.org/docs/current/perm-functions.html `ALTER ROLE :"username" SET search_path TO :'namespace';`, - // Allow the PgBouncer user to to login. + // Allow the PgBouncer user to login. `ALTER ROLE :"username" LOGIN PASSWORD :'verifier';`, // Commit (finish) the transaction. `COMMIT;`, }, "\n"), map[string]string{ - "username": postgresqlUser, + "username": PostgresqlUser, "namespace": postgresqlSchema, "verifier": string(clusterSecret.Data[verifierSecretKey]), @@ -195,29 +201,14 @@ REVOKE ALL PRIVILEGES return err } -func generatePassword() (plaintext, verifier string, err error) { - // PgBouncer can login to PostgreSQL using either MD5 or SCRAM-SHA-256. - // When using MD5, the (hashed) verifier can be stored in PgBouncer's - // authentication file. When using SCRAM, the plaintext password must be - // stored. - // - https://www.pgbouncer.org/config.html#authentication-file-format - // - https://github.com/pgbouncer/pgbouncer/issues/508#issuecomment-713339834 - - plaintext, err = util.GenerateASCIIPassword(32) - if err == nil { - verifier, err = password.NewSCRAMPassword(plaintext).Build() - } - return -} - -func postgresqlHBAs() []postgres.HostBasedAuthentication { +func postgresqlHBAs() []*postgres.HostBasedAuthentication { // PgBouncer must connect over TLS using a SCRAM password. Other network // connections are forbidden. // - https://www.postgresql.org/docs/current/auth-pg-hba-conf.html // - https://www.postgresql.org/docs/current/auth-password.html - return []postgres.HostBasedAuthentication{ - *postgres.NewHBA().User(postgresqlUser).TLS().Method("scram-sha-256"), - *postgres.NewHBA().User(postgresqlUser).TCP().Method("reject"), + return []*postgres.HostBasedAuthentication{ + postgres.NewHBA().Users(PostgresqlUser).TLS().Method("scram-sha-256"), + postgres.NewHBA().Users(PostgresqlUser).TCP().Method("reject"), } } diff --git a/internal/pgbouncer/postgres_test.go b/internal/pgbouncer/postgres_test.go index f2ce419753..3a4c1c3ef8 100644 --- a/internal/pgbouncer/postgres_test.go +++ b/internal/pgbouncer/postgres_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -19,14 +19,14 @@ import ( func TestSQLAuthenticationQuery(t *testing.T) { assert.Equal(t, sqlAuthenticationQuery("some.fn_name"), `CREATE OR REPLACE FUNCTION some.fn_name(username TEXT) -RETURNS TABLE(username TEXT, password TEXT) AS ' +RETURNS TABLE(username TEXT, password TEXT) AS E' SELECT rolname::TEXT, rolpassword::TEXT FROM pg_catalog.pg_authid WHERE pg_authid.rolname = $1 AND pg_authid.rolcanlogin AND NOT pg_authid.rolsuper AND NOT pg_authid.rolreplication - AND pg_authid.rolname <> ''_crunchypgbouncer'' + AND pg_authid.rolname <> E''_crunchypgbouncer'' AND (pg_authid.rolvaliduntil IS NULL OR pg_authid.rolvaliduntil >= CURRENT_TIMESTAMP)' LANGUAGE SQL STABLE SECURITY DEFINER;`) } @@ -49,6 +49,7 @@ func TestDisableInPostgreSQL(t *testing.T) { assert.NilError(t, err) assert.Equal(t, string(b), strings.TrimSpace(` SET client_min_messages = WARNING; +SET synchronous_commit = LOCAL; BEGIN; DROP FUNCTION IF EXISTS :"namespace".get_auth(username TEXT); DROP SCHEMA IF EXISTS :"namespace" CASCADE; @@ -90,7 +91,7 @@ COMMIT;`)) b, err := io.ReadAll(stdin) assert.NilError(t, err) - assert.Equal(t, string(b), `SET client_min_messages = WARNING; DROP ROLE IF EXISTS :"username";`) + assert.Equal(t, string(b), `SET client_min_messages = WARNING; SET synchronous_commit = LOCAL; DROP ROLE IF EXISTS :"username";`) gomega.NewWithT(t).Expect(command).To(gomega.ContainElements( `--set=username=_crunchypgbouncer`, ), "expected query parameters") @@ -135,6 +136,7 @@ func TestEnableInPostgreSQL(t *testing.T) { assert.NilError(t, err) assert.Equal(t, string(b), strings.TrimSpace(` SET client_min_messages = WARNING; +SET synchronous_commit = LOCAL; BEGIN; SELECT pg_catalog.format('CREATE ROLE %I NOLOGIN', :'username') WHERE NOT EXISTS (SELECT 1 FROM pg_catalog.pg_roles WHERE rolname = :'username') @@ -150,14 +152,14 @@ REVOKE ALL PRIVILEGES GRANT USAGE ON SCHEMA :"namespace" TO :"username"; CREATE OR REPLACE FUNCTION :"namespace".get_auth(username TEXT) -RETURNS TABLE(username TEXT, password TEXT) AS ' +RETURNS TABLE(username TEXT, password TEXT) AS E' SELECT rolname::TEXT, rolpassword::TEXT FROM pg_catalog.pg_authid WHERE pg_authid.rolname = $1 AND pg_authid.rolcanlogin AND NOT pg_authid.rolsuper AND NOT pg_authid.rolreplication - AND pg_authid.rolname <> ''_crunchypgbouncer'' + AND pg_authid.rolname <> E''_crunchypgbouncer'' AND (pg_authid.rolvaliduntil IS NULL OR pg_authid.rolvaliduntil >= CURRENT_TIMESTAMP)' LANGUAGE SQL STABLE SECURITY DEFINER; REVOKE ALL PRIVILEGES @@ -184,6 +186,6 @@ COMMIT;`)) func TestPostgreSQLHBAs(t *testing.T) { rules := postgresqlHBAs() assert.Equal(t, len(rules), 2) - assert.Equal(t, rules[0].String(), `hostssl all "_crunchypgbouncer" all scram-sha-256`) - assert.Equal(t, rules[1].String(), `host all "_crunchypgbouncer" all reject`) + assert.Equal(t, rules[0].String(), `hostssl all "_crunchypgbouncer" all "scram-sha-256"`) + assert.Equal(t, rules[1].String(), `host all "_crunchypgbouncer" all "reject"`) } diff --git a/internal/pgbouncer/reconcile.go b/internal/pgbouncer/reconcile.go index 999d6524a5..8eed54a3b6 100644 --- a/internal/pgbouncer/reconcile.go +++ b/internal/pgbouncer/reconcile.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -11,17 +11,21 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/pki" "github.com/crunchydata/postgres-operator/internal/postgres" + passwd "github.com/crunchydata/postgres-operator/internal/postgres/password" + "github.com/crunchydata/postgres-operator/internal/util" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) // ConfigMap populates the PgBouncer ConfigMap. func ConfigMap( + ctx context.Context, inCluster *v1beta1.PostgresCluster, outConfigMap *corev1.ConfigMap, ) { @@ -33,7 +37,7 @@ func ConfigMap( initialize.Map(&outConfigMap.Data) outConfigMap.Data[emptyConfigMapKey] = "" - outConfigMap.Data[iniFileConfigMapKey] = clusterINI(inCluster) + outConfigMap.Data[iniFileConfigMapKey] = clusterINI(ctx, inCluster) } // Secret populates the PgBouncer Secret. @@ -52,14 +56,29 @@ func Secret(ctx context.Context, var err error initialize.Map(&outSecret.Data) - // Use the existing password and verifier. Generate both when either is missing. + // Use the existing password and verifier. Generate when one is missing. + // PgBouncer can login to PostgreSQL using either MD5 or SCRAM-SHA-256. + // When using MD5, the (hashed) verifier can be stored in PgBouncer's + // authentication file. When using SCRAM, the plaintext password must be + // stored. + // - https://www.pgbouncer.org/config.html#authentication-file-format + // - https://github.com/pgbouncer/pgbouncer/issues/508#issuecomment-713339834 // NOTE(cbandy): We don't have a function to compare a plaintext password // to a SCRAM verifier. password := string(inSecret.Data[passwordSecretKey]) verifier := string(inSecret.Data[verifierSecretKey]) - if err == nil && (len(password) == 0 || len(verifier) == 0) { - password, verifier, err = generatePassword() + if len(password) == 0 { + // If the password is empty, generate new password and verifier. + password, err = util.GenerateASCIIPassword(32) + err = errors.WithStack(err) + if err == nil { + verifier, err = passwd.NewSCRAMPassword(password).Build() + err = errors.WithStack(err) + } + } else if len(password) != 0 && len(verifier) == 0 { + // If the password is non-empty and the verifier is empty, generate a new verifier. + verifier, err = passwd.NewSCRAMPassword(password).Build() err = errors.WithStack(err) } @@ -108,7 +127,7 @@ func Pod( inConfigMap *corev1.ConfigMap, inPostgreSQLCertificate *corev1.SecretProjection, inSecret *corev1.Secret, - outPod *corev1.PodSpec, + template *corev1.PodTemplateSpec, ) { if inCluster.Spec.Proxy == nil || inCluster.Spec.Proxy.PGBouncer == nil { // PgBouncer is disabled; there is nothing to do. @@ -177,16 +196,22 @@ func Pod( reloader.Resources = *inCluster.Spec.Proxy.PGBouncer.Sidecars.PGBouncerConfig.Resources } - outPod.Containers = []corev1.Container{container, reloader} + template.Spec.Containers = []corev1.Container{container, reloader} // If the PGBouncerSidecars feature gate is enabled and custom pgBouncer // sidecars are defined, add the defined container to the Pod. if feature.Enabled(ctx, feature.PGBouncerSidecars) && inCluster.Spec.Proxy.PGBouncer.Containers != nil { - outPod.Containers = append(outPod.Containers, inCluster.Spec.Proxy.PGBouncer.Containers...) + template.Spec.Containers = append(template.Spec.Containers, inCluster.Spec.Proxy.PGBouncer.Containers...) } - outPod.Volumes = []corev1.Volume{configVolume} + template.Spec.Volumes = []corev1.Volume{configVolume} + + if collector.OpenTelemetryLogsOrMetricsEnabled(ctx, inCluster) { + collector.AddToPod(ctx, inCluster.Spec.Instrumentation, inCluster.Spec.ImagePullPolicy, inConfigMap, + template, []corev1.VolumeMount{configVolumeMount}, string(inSecret.Data["pgbouncer-password"]), + []string{naming.PGBouncerLogPath}, true, true) + } } // PostgreSQL populates outHBAs with any records needed to run PgBouncer. diff --git a/internal/pgbouncer/reconcile_test.go b/internal/pgbouncer/reconcile_test.go index a53de8cf64..dd59a1a337 100644 --- a/internal/pgbouncer/reconcile_test.go +++ b/internal/pgbouncer/reconcile_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -22,6 +22,7 @@ import ( func TestConfigMap(t *testing.T) { t.Parallel() + ctx := context.Background() cluster := new(v1beta1.PostgresCluster) config := new(corev1.ConfigMap) @@ -29,7 +30,7 @@ func TestConfigMap(t *testing.T) { t.Run("Disabled", func(t *testing.T) { // Nothing happens when PgBouncer is disabled. constant := config.DeepCopy() - ConfigMap(cluster, config) + ConfigMap(ctx, cluster, config) assert.DeepEqual(t, constant, config) }) @@ -37,15 +38,15 @@ func TestConfigMap(t *testing.T) { cluster.Spec.Proxy.PGBouncer = new(v1beta1.PGBouncerPodSpec) cluster.Default() - ConfigMap(cluster, config) + ConfigMap(ctx, cluster, config) // The output of clusterINI should go into config. - data := clusterINI(cluster) + data := clusterINI(ctx, cluster) assert.DeepEqual(t, config.Data["pgbouncer.ini"], data) // No change when called again. before := config.DeepCopy() - ConfigMap(cluster, config) + ConfigMap(ctx, cluster, config) assert.DeepEqual(t, before, config) } @@ -90,6 +91,53 @@ func TestSecret(t *testing.T) { assert.DeepEqual(t, before, intent) } +func TestSCRAMVerifier(t *testing.T) { + t.Parallel() + + ctx := context.Background() + cluster := new(v1beta1.PostgresCluster) + service := new(corev1.Service) + existing := new(corev1.Secret) + intent := new(corev1.Secret) + + root, err := pki.NewRootCertificateAuthority() + assert.NilError(t, err) + + cluster.Spec.Proxy = new(v1beta1.PostgresProxySpec) + cluster.Spec.Proxy.PGBouncer = new(v1beta1.PGBouncerPodSpec) + cluster.Default() + + // Simulate the setting of a password only + existing.Data = map[string][]byte{ + "pgbouncer-password": []byte("password"), + } + + // Verify that a SCRAM verifier is set + assert.NilError(t, Secret(ctx, cluster, root, existing, service, intent)) + assert.Assert(t, len(intent.Data["pgbouncer-verifier"]) != 0) + + // Simulate the setting of a password and a verifier + intent = new(corev1.Secret) + existing.Data = map[string][]byte{ + "pgbouncer-verifier": []byte("SCRAM-SHA-256$4096:randomsalt:storedkey:serverkey"), + "pgbouncer-password": []byte("password"), + } + assert.NilError(t, Secret(ctx, cluster, root, existing, service, intent)) + assert.Equal(t, string(intent.Data["pgbouncer-verifier"]), "SCRAM-SHA-256$4096:randomsalt:storedkey:serverkey") + assert.Equal(t, string(intent.Data["pgbouncer-password"]), "password") + + // Simulate the setting of a verifier only + intent = new(corev1.Secret) + existing.Data = map[string][]byte{ + "pgbouncer-verifier": []byte("SCRAM-SHA-256$4096:randomsalt:storedkey:serverkey"), + } + assert.NilError(t, Secret(ctx, cluster, root, existing, service, intent)) + assert.Assert(t, string(intent.Data["pgbouncer-verifier"]) != "SCRAM-SHA-256$4096:randomsalt:storedkey:serverkey") + assert.Assert(t, len(intent.Data["pgbouncer-password"]) != 0) + assert.Assert(t, len(intent.Data["pgbouncer-verifier"]) != 0) + +} + func TestPod(t *testing.T) { t.Parallel() @@ -100,16 +148,16 @@ func TestPod(t *testing.T) { configMap := new(corev1.ConfigMap) primaryCertificate := new(corev1.SecretProjection) secret := new(corev1.Secret) - pod := new(corev1.PodSpec) + template := new(corev1.PodTemplateSpec) - call := func() { Pod(ctx, cluster, configMap, primaryCertificate, secret, pod) } + call := func() { Pod(ctx, cluster, configMap, primaryCertificate, secret, template) } t.Run("Disabled", func(t *testing.T) { - before := pod.DeepCopy() + before := template.DeepCopy() call() // No change when PgBouncer is not requested in the spec. - assert.DeepEqual(t, before, pod) + assert.DeepEqual(t, before, template) }) t.Run("Defaults", func(t *testing.T) { @@ -119,7 +167,7 @@ func TestPod(t *testing.T) { call() - assert.Assert(t, cmp.MarshalMatches(pod, ` + assert.Assert(t, cmp.MarshalMatches(template.Spec, ` containers: - command: - pgbouncer @@ -208,9 +256,9 @@ volumes: `)) // No change when called again. - before := pod.DeepCopy() + before := template.DeepCopy() call() - assert.DeepEqual(t, before, pod) + assert.DeepEqual(t, before, template) }) t.Run("Customizations", func(t *testing.T) { @@ -229,7 +277,7 @@ volumes: call() - assert.Assert(t, cmp.MarshalMatches(pod, ` + assert.Assert(t, cmp.MarshalMatches(template.Spec, ` containers: - command: - pgbouncer @@ -339,7 +387,7 @@ volumes: call() - assert.Assert(t, cmp.MarshalMatches(pod, ` + assert.Assert(t, cmp.MarshalMatches(template.Spec, ` containers: - command: - pgbouncer @@ -443,7 +491,7 @@ volumes: t.Run("SidecarNotEnabled", func(t *testing.T) { call() - assert.Equal(t, len(pod.Containers), 2, "expected 2 containers in Pod, got %d", len(pod.Containers)) + assert.Equal(t, len(template.Spec.Containers), 2, "expected 2 containers in Pod, got %d", len(template.Spec.Containers)) }) t.Run("SidecarEnabled", func(t *testing.T) { @@ -452,11 +500,11 @@ volumes: })) call() - assert.Equal(t, len(pod.Containers), 3, "expected 3 containers in Pod, got %d", len(pod.Containers)) + assert.Equal(t, len(template.Spec.Containers), 3, "expected 3 containers in Pod, got %d", len(template.Spec.Containers)) var found bool - for i := range pod.Containers { - if pod.Containers[i].Name == "customsidecar1" { + for i := range template.Spec.Containers { + if template.Spec.Containers[i].Name == "customsidecar1" { found = true break } @@ -491,6 +539,6 @@ func TestPostgreSQL(t *testing.T) { Mandatory: postgresqlHBAs(), }, // postgres.HostBasedAuthentication has unexported fields. Call String() to compare. - gocmp.Transformer("", postgres.HostBasedAuthentication.String)) + gocmp.Transformer("", (*postgres.HostBasedAuthentication).String)) }) } diff --git a/internal/pgmonitor/exporter.go b/internal/pgmonitor/exporter.go index 9d7a1fc3c6..c8422fcc2c 100644 --- a/internal/pgmonitor/exporter.go +++ b/internal/pgmonitor/exporter.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgmonitor/exporter_test.go b/internal/pgmonitor/exporter_test.go index 5ba14e0993..486b658dab 100644 --- a/internal/pgmonitor/exporter_test.go +++ b/internal/pgmonitor/exporter_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pgmonitor/postgres.go b/internal/pgmonitor/postgres.go index 8aed164a18..3ef83cd2e0 100644 --- a/internal/pgmonitor/postgres.go +++ b/internal/pgmonitor/postgres.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -10,6 +10,7 @@ import ( corev1 "k8s.io/api/core/v1" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/postgres" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" @@ -22,20 +23,23 @@ const ( // PostgreSQLHBAs provides the Postgres HBA rules for allowing the monitoring // exporter to be accessible -func PostgreSQLHBAs(inCluster *v1beta1.PostgresCluster, outHBAs *postgres.HBAs) { - if ExporterEnabled(inCluster) { +func PostgreSQLHBAs(ctx context.Context, inCluster *v1beta1.PostgresCluster, outHBAs *postgres.HBAs) { + if ExporterEnabled(ctx, inCluster) || + collector.OpenTelemetryMetricsEnabled(ctx, inCluster) { // Limit the monitoring user to local connections using SCRAM. outHBAs.Mandatory = append(outHBAs.Mandatory, - *postgres.NewHBA().TCP().User(MonitoringUser).Method("scram-sha-256").Network("127.0.0.0/8"), - *postgres.NewHBA().TCP().User(MonitoringUser).Method("scram-sha-256").Network("::1/128"), - *postgres.NewHBA().TCP().User(MonitoringUser).Method("reject")) + postgres.NewHBA().TCP().Users(MonitoringUser).Method("scram-sha-256").Network("127.0.0.0/8"), + postgres.NewHBA().TCP().Users(MonitoringUser).Method("scram-sha-256").Network("::1/128"), + postgres.NewHBA().TCP().Users(MonitoringUser).Method("reject")) } } // PostgreSQLParameters provides additional required configuration parameters -// that Postgres needs to support monitoring -func PostgreSQLParameters(inCluster *v1beta1.PostgresCluster, outParameters *postgres.Parameters) { - if ExporterEnabled(inCluster) { +// that Postgres needs to support monitoring for both pgMonitor and OTel +func PostgreSQLParameters(ctx context.Context, inCluster *v1beta1.PostgresCluster, outParameters *postgres.Parameters) { + if ExporterEnabled(ctx, inCluster) || + collector.OpenTelemetryMetricsEnabled(ctx, inCluster) { + // Exporter expects that shared_preload_libraries are installed // pg_stat_statements: https://access.crunchydata.com/documentation/pgmonitor/latest/exporter/ // pgnodemx: https://github.com/CrunchyData/pgnodemx @@ -45,11 +49,11 @@ func PostgreSQLParameters(inCluster *v1beta1.PostgresCluster, outParameters *pos } } -// DisableExporterInPostgreSQL disables the exporter configuration in PostgreSQL. +// DisableMonitoringUserInPostgres disables the exporter configuration in PostgreSQL. // Currently the exporter is disabled by removing login permissions for the // monitoring user. // TODO: evaluate other uninstall/removal options -func DisableExporterInPostgreSQL(ctx context.Context, exec postgres.Executor) error { +func DisableMonitoringUserInPostgres(ctx context.Context, exec postgres.Executor) error { log := logging.FromContext(ctx) stdout, stderr, err := exec.Exec(ctx, strings.NewReader(` @@ -79,6 +83,10 @@ func EnableExporterInPostgreSQL(ctx context.Context, exec postgres.Executor, // - https://www.postgresql.org/docs/current/runtime-config-client.html `SET client_min_messages = WARNING;`, + // Do not wait for changes to be replicated. [Since PostgreSQL v9.1] + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + `SET synchronous_commit = LOCAL;`, + // Exporter expects that extension(s) to be installed in all databases // pg_stat_statements: https://access.crunchydata.com/documentation/pgmonitor/latest/exporter/ "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;", @@ -103,6 +111,10 @@ func EnableExporterInPostgreSQL(ctx context.Context, exec postgres.Executor, // - https://www.postgresql.org/docs/current/runtime-config-client.html `SET client_min_messages = WARNING;`, + // Do not wait for changes to be replicated. [Since PostgreSQL v9.1] + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + `SET synchronous_commit = LOCAL;`, + // Setup.sql file from the exporter image. sql is specific // to the PostgreSQL version setup, diff --git a/internal/pgmonitor/postgres_test.go b/internal/pgmonitor/postgres_test.go index 655fa936ae..4c1acc1dcf 100644 --- a/internal/pgmonitor/postgres_test.go +++ b/internal/pgmonitor/postgres_test.go @@ -1,10 +1,11 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package pgmonitor import ( + "context" "strings" "testing" @@ -15,10 +16,12 @@ import ( ) func TestPostgreSQLHBA(t *testing.T) { + ctx := context.Background() + t.Run("ExporterDisabled", func(t *testing.T) { inCluster := &v1beta1.PostgresCluster{} outHBAs := postgres.HBAs{} - PostgreSQLHBAs(inCluster, &outHBAs) + PostgreSQLHBAs(ctx, inCluster, &outHBAs) assert.Equal(t, len(outHBAs.Mandatory), 0) }) @@ -33,20 +36,22 @@ func TestPostgreSQLHBA(t *testing.T) { } outHBAs := postgres.HBAs{} - PostgreSQLHBAs(inCluster, &outHBAs) + PostgreSQLHBAs(ctx, inCluster, &outHBAs) assert.Equal(t, len(outHBAs.Mandatory), 3) - assert.Equal(t, outHBAs.Mandatory[0].String(), `host all "ccp_monitoring" "127.0.0.0/8" scram-sha-256`) - assert.Equal(t, outHBAs.Mandatory[1].String(), `host all "ccp_monitoring" "::1/128" scram-sha-256`) - assert.Equal(t, outHBAs.Mandatory[2].String(), `host all "ccp_monitoring" all reject`) + assert.Equal(t, outHBAs.Mandatory[0].String(), `host all "ccp_monitoring" "127.0.0.0/8" "scram-sha-256"`) + assert.Equal(t, outHBAs.Mandatory[1].String(), `host all "ccp_monitoring" "::1/128" "scram-sha-256"`) + assert.Equal(t, outHBAs.Mandatory[2].String(), `host all "ccp_monitoring" all "reject"`) }) } func TestPostgreSQLParameters(t *testing.T) { + ctx := context.Background() + t.Run("ExporterDisabled", func(t *testing.T) { inCluster := &v1beta1.PostgresCluster{} outParameters := postgres.NewParameters() - PostgreSQLParameters(inCluster, &outParameters) + PostgreSQLParameters(ctx, inCluster, &outParameters) assert.Assert(t, !outParameters.Mandatory.Has("shared_preload_libraries")) }) @@ -61,7 +66,7 @@ func TestPostgreSQLParameters(t *testing.T) { } outParameters := postgres.NewParameters() - PostgreSQLParameters(inCluster, &outParameters) + PostgreSQLParameters(ctx, inCluster, &outParameters) libs, found := outParameters.Mandatory.Get("shared_preload_libraries") assert.Assert(t, found) assert.Assert(t, strings.Contains(libs, "pg_stat_statements")) @@ -80,7 +85,7 @@ func TestPostgreSQLParameters(t *testing.T) { outParameters := postgres.NewParameters() outParameters.Mandatory.Add("shared_preload_libraries", "daisy") - PostgreSQLParameters(inCluster, &outParameters) + PostgreSQLParameters(ctx, inCluster, &outParameters) libs, found := outParameters.Mandatory.Get("shared_preload_libraries") assert.Assert(t, found) assert.Assert(t, strings.Contains(libs, "pg_stat_statements")) diff --git a/internal/pgmonitor/util.go b/internal/pgmonitor/util.go index f5606ccd08..76a8a6adae 100644 --- a/internal/pgmonitor/util.go +++ b/internal/pgmonitor/util.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -8,6 +8,7 @@ import ( "context" "os" + "github.com/crunchydata/postgres-operator/internal/collector" "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -26,7 +27,12 @@ func GetQueriesConfigDir(ctx context.Context) string { } // ExporterEnabled returns true if the monitoring exporter is enabled -func ExporterEnabled(cluster *v1beta1.PostgresCluster) bool { +func ExporterEnabled(ctx context.Context, cluster *v1beta1.PostgresCluster) bool { + // If OpenTelemetry metrics are enabled for this cluster, that takes precedence + // over the postgres_exporter metrics. + if collector.OpenTelemetryMetricsEnabled(ctx, cluster) { + return false + } if cluster.Spec.Monitoring == nil { return false } diff --git a/internal/pgmonitor/util_test.go b/internal/pgmonitor/util_test.go index 8d16d74bae..e862e87a67 100644 --- a/internal/pgmonitor/util_test.go +++ b/internal/pgmonitor/util_test.go @@ -1,28 +1,46 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package pgmonitor import ( + "context" "testing" "gotest.tools/v3/assert" + "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) func TestExporterEnabled(t *testing.T) { cluster := &v1beta1.PostgresCluster{} - assert.Assert(t, !ExporterEnabled(cluster)) + ctx := context.Background() + assert.Assert(t, !ExporterEnabled(ctx, cluster)) cluster.Spec.Monitoring = &v1beta1.MonitoringSpec{} - assert.Assert(t, !ExporterEnabled(cluster)) + assert.Assert(t, !ExporterEnabled(ctx, cluster)) cluster.Spec.Monitoring.PGMonitor = &v1beta1.PGMonitorSpec{} - assert.Assert(t, !ExporterEnabled(cluster)) + assert.Assert(t, !ExporterEnabled(ctx, cluster)) cluster.Spec.Monitoring.PGMonitor.Exporter = &v1beta1.ExporterSpec{} - assert.Assert(t, ExporterEnabled(cluster)) - + assert.Assert(t, ExporterEnabled(ctx, cluster)) + + // Enabling the OpenTelemetryMetrics is not sufficient to disable the exporter + gate := feature.NewGate() + assert.NilError(t, gate.SetFromMap(map[string]bool{ + feature.OpenTelemetryMetrics: true, + })) + ctx = feature.NewContext(ctx, gate) + assert.Assert(t, ExporterEnabled(ctx, cluster)) + + require.UnmarshalInto(t, &cluster.Spec, `{ + instrumentation: { + logs: { retentionPeriod: 5h }, + }, + }`) + assert.Assert(t, !ExporterEnabled(ctx, cluster)) } diff --git a/internal/pki/common.go b/internal/pki/common.go index fbe9421f8b..9075931289 100644 --- a/internal/pki/common.go +++ b/internal/pki/common.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pki/doc.go b/internal/pki/doc.go index 71f8c0a1bc..8bd238e904 100644 --- a/internal/pki/doc.go +++ b/internal/pki/doc.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pki/encoding.go b/internal/pki/encoding.go index 2d2cd851e3..f3d45ffc70 100644 --- a/internal/pki/encoding.go +++ b/internal/pki/encoding.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pki/encoding_test.go b/internal/pki/encoding_test.go index cdf7c0de5a..eb2b1365b3 100644 --- a/internal/pki/encoding_test.go +++ b/internal/pki/encoding_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -81,7 +81,7 @@ func TestCertificateTextMarshaling(t *testing.T) { assert.NilError(t, os.WriteFile(certFile, certBytes, 0o600)) // The "openssl x509" command parses X.509 certificates. - cmd := exec.Command(openssl, "x509", + cmd := exec.CommandContext(t.Context(), openssl, "x509", "-in", certFile, "-inform", "PEM", "-noout", "-text") output, err := cmd.CombinedOutput() @@ -153,7 +153,7 @@ func TestPrivateKeyTextMarshaling(t *testing.T) { assert.NilError(t, os.WriteFile(keyFile, keyBytes, 0o600)) // The "openssl pkey" command processes public and private keys. - cmd := exec.Command(openssl, "pkey", + cmd := exec.CommandContext(t.Context(), openssl, "pkey", "-in", keyFile, "-inform", "PEM", "-noout", "-text") output, err := cmd.CombinedOutput() @@ -164,12 +164,12 @@ func TestPrivateKeyTextMarshaling(t *testing.T) { "expected valid private key, got:\n%s", output) t.Run("Check", func(t *testing.T) { - output, _ := exec.Command(openssl, "pkey", "-help").CombinedOutput() + output, _ := exec.CommandContext(t.Context(), openssl, "pkey", "-help").CombinedOutput() if !strings.Contains(string(output), "-check") { t.Skip(`requires "-check" flag`) } - cmd := exec.Command(openssl, "pkey", + cmd := exec.CommandContext(t.Context(), openssl, "pkey", "-check", "-in", keyFile, "-inform", "PEM", "-noout", "-text") output, err := cmd.CombinedOutput() diff --git a/internal/pki/pki.go b/internal/pki/pki.go index 7048810654..80f16fb2e5 100644 --- a/internal/pki/pki.go +++ b/internal/pki/pki.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/pki/pki_test.go b/internal/pki/pki_test.go index cd13896450..fa8f290475 100644 --- a/internal/pki/pki_test.go +++ b/internal/pki/pki_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -194,7 +194,7 @@ func TestRootIsInvalid(t *testing.T) { t.Cleanup(func() { currentTime = original }) currentTime = func() time.Time { - return time.Date(2010, time.January, 1, 0, 0, 0, 0, time.Local) + return time.Date(2010, time.January, 1, 0, 0, 0, 0, time.UTC) } root, err := NewRootCertificateAuthority() @@ -395,7 +395,7 @@ func TestLeafIsInvalid(t *testing.T) { t.Cleanup(func() { currentTime = original }) currentTime = func() time.Time { - return time.Date(2010, time.January, 1, 0, 0, 0, 0, time.Local) + return time.Date(2010, time.January, 1, 0, 0, 0, 0, time.UTC) } leaf, err := root.GenerateLeafCertificate("", nil) @@ -439,7 +439,7 @@ func basicOpenSSLVerify(t *testing.T, openssl string, root, leaf Certificate) { verify := func(t testing.TB, args ...string) { t.Helper() // #nosec G204 -- args from this test - cmd := exec.Command(openssl, append([]string{"verify"}, args...)...) + cmd := exec.CommandContext(t.Context(), openssl, append([]string{"verify"}, args...)...) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) @@ -476,7 +476,7 @@ func basicOpenSSLVerify(t *testing.T, openssl string, root, leaf Certificate) { } func strictOpenSSLVerify(t *testing.T, openssl string, root, leaf Certificate) { - output, _ := exec.Command(openssl, "verify", "-help").CombinedOutput() + output, _ := exec.CommandContext(t.Context(), openssl, "verify", "-help").CombinedOutput() if !strings.Contains(string(output), "-x509_strict") { t.Skip(`requires "-x509_strict" flag`) } @@ -487,7 +487,7 @@ func strictOpenSSLVerify(t *testing.T, openssl string, root, leaf Certificate) { verify := func(t testing.TB, args ...string) { t.Helper() // #nosec G204 -- args from this test - cmd := exec.Command(openssl, append([]string{"verify", + cmd := exec.CommandContext(t.Context(), openssl, append([]string{"verify", // Do not use the default trusted CAs. "-no-CAfile", "-no-CApath", // Disable "non-compliant workarounds for broken certificates". diff --git a/internal/postgis/postgis.go b/internal/postgis/postgis.go index f54da0dd93..5a90c7afe2 100644 --- a/internal/postgis/postgis.go +++ b/internal/postgis/postgis.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -26,6 +26,10 @@ func EnableInPostgreSQL(ctx context.Context, exec postgres.Executor) error { // - https://www.postgresql.org/docs/current/runtime-config-client.html `SET client_min_messages = WARNING;`, + // Do not wait for changes to be replicated. [Since PostgreSQL v9.1] + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + `SET synchronous_commit = LOCAL;`, + `CREATE EXTENSION IF NOT EXISTS postgis;`, `CREATE EXTENSION IF NOT EXISTS postgis_topology;`, `CREATE EXTENSION IF NOT EXISTS fuzzystrmatch;`, diff --git a/internal/postgis/postgis_test.go b/internal/postgis/postgis_test.go index 5f604abc90..7e83c840e9 100644 --- a/internal/postgis/postgis_test.go +++ b/internal/postgis/postgis_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -29,6 +29,7 @@ func TestEnableInPostgreSQL(t *testing.T) { b, err := io.ReadAll(stdin) assert.NilError(t, err) assert.Equal(t, string(b), `SET client_min_messages = WARNING; +SET synchronous_commit = LOCAL; CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS postgis_topology; CREATE EXTENSION IF NOT EXISTS fuzzystrmatch; diff --git a/internal/postgres/config.go b/internal/postgres/config.go index ce1acde3fb..65c26dec6d 100644 --- a/internal/postgres/config.go +++ b/internal/postgres/config.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,13 +7,16 @@ package postgres import ( "context" "fmt" + "math" "strings" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/naming" + "github.com/crunchydata/postgres-operator/internal/shell" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -57,6 +60,9 @@ safelink() ( // dataMountPath is where to mount the main data volume. tablespaceMountPath = "/tablespaces" + // tmpMountPath is where to mount the optional ephemeral volume. + tmpMountPath = "/pgtmp" + // walMountPath is where to mount the optional WAL volume. walMountPath = "/pgwal" @@ -70,7 +76,7 @@ safelink() ( // for streaming replication and for `pg_rewind`. ReplicationUser = "_crunchyrepl" - // configMountPath is where to mount additional config files + // configMountPath is where to mount config files. configMountPath = "/etc/postgres" ) @@ -86,6 +92,77 @@ func DataDirectory(cluster *v1beta1.PostgresCluster) string { return fmt.Sprintf("%s/pg%d", dataMountPath, cluster.Spec.PostgresVersion) } +// LogDirectory returns the absolute path to the "log_directory" of cluster. +// - https://www.postgresql.org/docs/current/runtime-config-logging.html +func LogDirectory() string { + return fmt.Sprintf("%s/logs/postgres", dataMountPath) +} + +// LogRotation returns parameters that rotate log files while keeping a minimum amount. +// Postgres truncates and reuses log files after that minimum amount. +// Log file names start with filePrefix and end with fileSuffix. +// +// NOTE: These parameters do *not* enable logging to files. Set "logging_collector" for that. +func LogRotation(minimum metav1.Duration, filePrefix, fileSuffix string) map[string]string { + hours := math.Ceil(minimum.Hours()) + + // The "log_filename" parameter is interpreted similar to `strftime`; + // escape percent U+0025 by doubling it. + // - https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-FILENAME + prefix := strings.ReplaceAll(filePrefix, "%", "%%") + suffix := strings.ReplaceAll(fileSuffix, "%", "%%") + + // Postgres can "rotate" its own log files by calculating log_filename as needed. + // However, the automated portions of log_filename are *entirely* based on time. + // An inappropriate pairing of log_filename with other logging parameters could lose log messages. + // + // TODO(logs): Limit the size/bytes of logs without losing messages; + // probably requires another process that deletes the oldest files. TODO(sidecar) + // + // The parameter combinations below have Postgres discard log messages and reuse log files + // only after the minimum time has elapsed. + + result := map[string]string{ + // Discard old messages when log_filename is reused due to rotation. + "log_truncate_on_rotation": "on", + + // To not lose messages, log_rotation_size must be larger than the volume of messages emitted before log_filename changes. + // Rather than monitor and accommodate that, disable rotation by size completely. + "log_rotation_size": "0", + } + + // These pairings have Postgres log to multiple files so a log consumer + // has the opportunity to read a prior file while Postgres truncates the next. + switch { + case hours <= 1: + // One hour of logs in minute-long files + result["log_filename"] = prefix + "%M" + suffix + result["log_rotation_age"] = "1min" + + case hours <= 24: + // One day of logs in hour-long files + result["log_filename"] = prefix + "%H" + suffix + result["log_rotation_age"] = "1h" + + case hours <= 24*7: + // One week of logs in day-long files + result["log_filename"] = prefix + "%a" + suffix + result["log_rotation_age"] = "1d" + + case hours <= 24*28: + // One month of logs in day-long files + result["log_filename"] = prefix + "%d" + suffix + result["log_rotation_age"] = "1d" + + default: + // One year of logs in day-long files + result["log_filename"] = prefix + "%j" + suffix + result["log_rotation_age"] = "1d" + } + + return result +} + // WALDirectory returns the absolute path to the directory where an instance // stores its WAL files. // - https://www.postgresql.org/docs/current/wal.html @@ -187,6 +264,25 @@ NAMESPACE=$(cat ${SERVICEACCOUNT}/namespace) TOKEN=$(cat ${SERVICEACCOUNT}/token) CACERT=${SERVICEACCOUNT}/ca.crt +# Manage autogrow annotation. +# Return size in Mebibytes. +manageAutogrowAnnotation() { + local volume=$1 + + size=$(df --human-readable --block-size=M /"${volume}" | awk 'FNR == 2 {print $2}') + use=$(df --human-readable /"${volume}" | awk 'FNR == 2 {print $5}') + sizeInt="${size//M/}" + # Use the sed punctuation class, because the shell will not accept the percent sign in an expansion. + useInt=$(echo $use | sed 's/[[:punct:]]//g') + triggerExpansion="$((useInt > 75))" + if [ $triggerExpansion -eq 1 ]; then + newSize="$(((sizeInt / 2)+sizeInt))" + newSizeMi="${newSize}Mi" + d='[{"op": "add", "path": "/metadata/annotations/suggested-'"${volume}"'-pvc-size", "value": "'"$newSizeMi"'"}]' + curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -XPATCH "${APISERVER}/api/v1/namespaces/${NAMESPACE}/pods/${HOSTNAME}?fieldManager=kubectl-annotate" -H "Content-Type: application/json-patch+json" --data "$d" + fi +} + declare -r directory=%q exec {fd}<> <(:||:) while read -r -t 5 -u "${fd}" ||:; do @@ -199,20 +295,8 @@ while read -r -t 5 -u "${fd}" ||:; do stat --format='Loaded certificates dated %%y' "${directory}" fi - # Manage autogrow annotation. - # Return size in Mebibytes. - size=$(df --human-readable --block-size=M /pgdata | awk 'FNR == 2 {print $2}') - use=$(df --human-readable /pgdata | awk 'FNR == 2 {print $5}') - sizeInt="${size//M/}" - # Use the sed punctuation class, because the shell will not accept the percent sign in an expansion. - useInt=$(echo $use | sed 's/[[:punct:]]//g') - triggerExpansion="$((useInt > 75))" - if [ $triggerExpansion -eq 1 ]; then - newSize="$(((sizeInt / 2)+sizeInt))" - newSizeMi="${newSize}Mi" - d='[{"op": "add", "path": "/metadata/annotations/suggested-pgdata-pvc-size", "value": "'"$newSizeMi"'"}]' - curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -XPATCH "${APISERVER}/api/v1/namespaces/${NAMESPACE}/pods/${HOSTNAME}?fieldManager=kubectl-annotate" -H "Content-Type: application/json-patch+json" --data "$d" - fi + # manage autogrow annotation for the pgData volume + manageAutogrowAnnotation "pgdata" done `, naming.CertMountPath, @@ -222,6 +306,11 @@ done naming.ReplicationCACertPath, ) + // this is used to close out the while loop started above after adding the required + // auto grow annotation scripts + // finalDone := `done + // ` + // Elide the above script from `ps` and `top` by wrapping it in a function // and calling that. wrapper := `monitor() {` + script + `}; export -f monitor; exec -a "$0" bash -ceu monitor` @@ -264,9 +353,9 @@ func startupCommand( // and so we add the subdirectory `data` in order to set the permissions. checkInstallRecreateCmd := strings.Join([]string{ `if [[ ! -e "${tablespace_dir}" || -O "${tablespace_dir}" ]]; then`, - `install --directory --mode=0700 "${tablespace_dir}"`, + `install --directory --mode=0750 "${tablespace_dir}"`, `elif [[ -w "${tablespace_dir}" && -g "${tablespace_dir}" ]]; then`, - `recreate "${tablespace_dir}" '0700'`, + `recreate "${tablespace_dir}" '0750'`, `else (halt Permissions!); fi ||`, `halt "$(permissions "${tablespace_dir}" ||:)"`, }, "\n") @@ -291,9 +380,9 @@ chmod +x /tmp/pg_rewind_tde.sh ` } - args := []string{version, walDir, naming.PGBackRestPGDataLogPath} + args := []string{version, walDir} script := strings.Join([]string{ - `declare -r expected_major_version="$1" pgwal_directory="$2" pgbrLog_directory="$3"`, + `declare -r expected_major_version="$1" pgwal_directory="$2"`, // Function to print the permissions of a file or directory and its parents. bashPermissions, @@ -342,32 +431,36 @@ chmod +x /tmp/pg_rewind_tde.sh // PostgreSQL requires its directory to be writable by only itself. // Pod "securityContext.fsGroup" sets g+w on directories for *some* // storage providers. Ensure the current user owns the directory, and - // remove group permissions. + // remove group-write permission. // - https://www.postgresql.org/docs/current/creating-cluster.html // - https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/backend/postmaster/postmaster.c;hb=REL_10_0#l1522 - // - https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/backend/utils/init/miscinit.c;hb=REL_14_0#l349 + // - https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/backend/utils/init/miscinit.c;hb=REL_11_0#l142 + // - https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/backend/utils/init/miscinit.c;hb=REL_17_0#l386 // - https://issue.k8s.io/93802#issuecomment-717646167 // // When the directory does not exist, create it with the correct permissions. // When the directory has the correct owner, set the correct permissions. `if [[ ! -e "${postgres_data_directory}" || -O "${postgres_data_directory}" ]]; then`, - `install --directory --mode=0700 "${postgres_data_directory}"`, + `install --directory --mode=0750 "${postgres_data_directory}"`, // // The directory exists but its owner is wrong. When it is writable, // the set-group-ID bit indicates that "fsGroup" probably ran on its // contents making them safe to use. In this case, we can make a new // directory (owned by this user) and refill it. `elif [[ -w "${postgres_data_directory}" && -g "${postgres_data_directory}" ]]; then`, - `recreate "${postgres_data_directory}" '0700'`, + `recreate "${postgres_data_directory}" '0750'`, // // The directory exists, its owner is wrong, and it is not writable. `else (halt Permissions!); fi ||`, `halt "$(permissions "${postgres_data_directory}" ||:)"`, - // Create the pgBackRest log directory. - `results 'pgBackRest log directory' "${pgbrLog_directory}"`, - `install --directory --mode=0775 "${pgbrLog_directory}" ||`, - `halt "$(permissions "${pgbrLog_directory}" ||:)"`, + // Create log directories. + `(` + shell.MakeDirectories(dataMountPath, naming.PGBackRestPGDataLogPath) + `) ||`, + `halt "$(permissions ` + naming.PGBackRestPGDataLogPath + ` ||:)"`, + `(` + shell.MakeDirectories(dataMountPath, naming.PatroniPGDataLogPath) + `) ||`, + `halt "$(permissions ` + naming.PatroniPGDataLogPath + ` ||:)"`, + `(` + shell.MakeDirectories(dataMountPath, LogDirectory()) + `) ||`, + `halt "$(permissions ` + LogDirectory() + ` ||:)"`, // Copy replication client certificate files // from the /pgconf/tls/replication directory to the /tmp/replication directory in order @@ -412,15 +505,6 @@ chmod +x /tmp/pg_rewind_tde.sh // - https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/bin/pg_basebackup/pg_basebackup.c;hb=REL_13_0#l2621 `safelink "${pgwal_directory}" "${postgres_data_directory}/pg_wal"`, `results 'wal directory' "$(realpath "${postgres_data_directory}/pg_wal" ||:)"`, - - // Early versions of PGO create replicas with a recovery signal file. - // Patroni also creates a standby signal file before starting Postgres, - // causing Postgres to remove only one, the standby. Remove the extra - // signal file now, if it exists, and let Patroni manage the standby - // signal file instead. - // - https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/backend/access/transam/xlog.c;hb=REL_12_0#l5318 - // TODO(cbandy): Remove this after 5.0 is EOL. - `rm -f "${postgres_data_directory}/recovery.signal"`, }, "\n") return append([]string{"bash", "-ceu", "--", script, "startup"}, args...) diff --git a/internal/postgres/config_test.go b/internal/postgres/config_test.go index cd4c92d185..762bd8a0b9 100644 --- a/internal/postgres/config_test.go +++ b/internal/postgres/config_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -9,14 +9,16 @@ import ( "context" "errors" "fmt" + "math/rand/v2" "os" "os/exec" "path/filepath" "strings" "testing" + "time" "gotest.tools/v3/assert" - corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/yaml" "github.com/crunchydata/postgres-operator/internal/testing/cmp" @@ -38,6 +40,86 @@ func TestDataDirectory(t *testing.T) { assert.Equal(t, DataDirectory(cluster), "/pgdata/pg12") } +func TestLogRotation(t *testing.T) { + t.Parallel() + + const Day = 24 * time.Hour + + random := func(low, high time.Duration) time.Duration { + return low + rand.N(high-low) + } + + for _, tt := range []struct { + duration time.Duration + prefix string + suffix string + expected map[string]string + }{ + // Small duration becomes one hour split into minutes + {duration: random(1, time.Hour), + expected: map[string]string{ + "log_filename": "%M", // two-digit minute [00, 59] + "log_rotation_age": "1min", // × 1 minute = 1 hour + "log_rotation_size": "0", + "log_truncate_on_rotation": "on", + }}, + + // More than an hour becomes one day split into hours + {duration: random(90*time.Minute, 24*time.Hour), + expected: map[string]string{ + "log_filename": "%H", // two-digit hour [00,23] + "log_rotation_age": "1h", // × 1 hour = 1 day + "log_rotation_size": "0", + "log_truncate_on_rotation": "on", + }}, + + // More than one day becomes one week split into days + {duration: random(3*Day, 7*Day), + expected: map[string]string{ + "log_filename": "%a", // locale weekday name + "log_rotation_age": "1d", // × 1 day = 1 week + "log_rotation_size": "0", + "log_truncate_on_rotation": "on", + }}, + + // More than one week becomes one month split into days + {duration: random(11*Day, 25*Day), + expected: map[string]string{ + "log_filename": "%d", // two-digit day of the month [01, 31] + "log_rotation_age": "1d", // × 1 day = 1 month + "log_rotation_size": "0", + "log_truncate_on_rotation": "on", + }}, + + // More than one month becomes one year split into days + {duration: random(70*Day, 300*Day), + expected: map[string]string{ + "log_filename": "%j", // three-digit day of the year [001, 366] + "log_rotation_age": "1d", // × 1 day = 1 year + "log_rotation_size": "0", + "log_truncate_on_rotation": "on", + }}, + } { + t.Run(tt.duration.String(), func(t *testing.T) { + actual := LogRotation(metav1.Duration{Duration: tt.duration}, tt.prefix, tt.suffix) + assert.DeepEqual(t, tt.expected, actual) + }) + } + + t.Run("Escaping", func(t *testing.T) { + // any duration + duration := metav1.Duration{Duration: random(1, 350*Day)} + + // double-percent prefix + assert.Assert(t, cmp.Regexp(`^as%%ddf%[^%]qwerty$`, + LogRotation(duration, "as%ddf", "qwerty")["log_filename"])) + + // double-percent suffix + assert.Assert(t, cmp.Regexp(`^postgres-%[^%]-x%%y%%zzz$`, + LogRotation(duration, "postgres-", "-x%y%zzz")["log_filename"])) + }) +} + func TestWALDirectory(t *testing.T) { cluster := new(v1beta1.PostgresCluster) cluster.Spec.PostgresVersion = 13 @@ -47,13 +129,13 @@ func TestWALDirectory(t *testing.T) { assert.Equal(t, WALDirectory(cluster, instance), "/pgdata/pg13_wal") // with WAL volume - instance.WALVolumeClaimSpec = new(corev1.PersistentVolumeClaimSpec) + instance.WALVolumeClaimSpec = new(v1beta1.VolumeClaimSpec) assert.Equal(t, WALDirectory(cluster, instance), "/pgwal/pg13_wal") } func TestBashHalt(t *testing.T) { t.Run("NoPipeline", func(t *testing.T) { - cmd := exec.Command("bash") + cmd := exec.CommandContext(t.Context(), "bash") cmd.Args = append(cmd.Args, "-c", "--", bashHalt+`; halt ab cd e`) var exit *exec.ExitError @@ -65,7 +147,7 @@ func TestBashHalt(t *testing.T) { }) t.Run("PipelineZeroStatus", func(t *testing.T) { - cmd := exec.Command("bash") + cmd := exec.CommandContext(t.Context(), "bash") cmd.Args = append(cmd.Args, "-c", "--", bashHalt+`; true && halt message`) var exit *exec.ExitError @@ -77,7 +159,7 @@ func TestBashHalt(t *testing.T) { }) t.Run("PipelineNonZeroStatus", func(t *testing.T) { - cmd := exec.Command("bash") + cmd := exec.CommandContext(t.Context(), "bash") cmd.Args = append(cmd.Args, "-c", "--", bashHalt+`; (exit 99) || halt $'multi\nline'`) var exit *exec.ExitError @@ -89,7 +171,7 @@ func TestBashHalt(t *testing.T) { }) t.Run("Subshell", func(t *testing.T) { - cmd := exec.Command("bash") + cmd := exec.CommandContext(t.Context(), "bash") cmd.Args = append(cmd.Args, "-c", "--", bashHalt+`; (halt 'err') || echo 'after'`) stderr := new(bytes.Buffer) @@ -105,7 +187,7 @@ func TestBashHalt(t *testing.T) { func TestBashPermissions(t *testing.T) { // macOS `stat` takes different arguments than BusyBox and GNU coreutils. - if output, err := exec.Command("stat", "--help").CombinedOutput(); err != nil { + if output, err := exec.CommandContext(t.Context(), "stat", "--help").CombinedOutput(); err != nil { t.Skip(`requires "stat" executable`) } else if !strings.Contains(string(output), "%A") { t.Skip(`requires "stat" with access format sequence`) @@ -117,7 +199,7 @@ func TestBashPermissions(t *testing.T) { assert.NilError(t, os.WriteFile(filepath.Join(dir, "sub", "fn"), nil, 0o624)) // #nosec G306 OK permissions for a temp dir in a test assert.NilError(t, os.Chmod(filepath.Join(dir, "sub", "fn"), 0o624)) - cmd := exec.Command("bash") + cmd := exec.CommandContext(t.Context(), "bash") cmd.Args = append(cmd.Args, "-c", "--", bashPermissions+`; permissions "$@"`, "-", filepath.Join(dir, "sub", "fn")) @@ -132,7 +214,7 @@ func TestBashPermissions(t *testing.T) { func TestBashRecreateDirectory(t *testing.T) { // macOS `stat` takes different arguments than BusyBox and GNU coreutils. - if output, err := exec.Command("stat", "--help").CombinedOutput(); err != nil { + if output, err := exec.CommandContext(t.Context(), "stat", "--help").CombinedOutput(); err != nil { t.Skip(`requires "stat" executable`) } else if !strings.Contains(string(output), "%a") { t.Skip(`requires "stat" with access format sequence`) @@ -144,7 +226,7 @@ func TestBashRecreateDirectory(t *testing.T) { assert.NilError(t, os.WriteFile(filepath.Join(dir, "d", "file"), nil, 0o644)) // #nosec G306 OK permissions for a temp dir in a test stat := func(args ...string) string { - cmd := exec.Command("stat", "-c", "%i %#a %N") + cmd := exec.CommandContext(t.Context(), "stat", "-c", "%i %#a %N") cmd.Args = append(cmd.Args, args...) out, err := cmd.CombinedOutput() @@ -161,7 +243,7 @@ func TestBashRecreateDirectory(t *testing.T) { filepath.Join(dir, "d", "file"), ) - cmd := exec.Command("bash") + cmd := exec.CommandContext(t.Context(), "bash") cmd.Args = append(cmd.Args, "-ceu", "--", bashRecreateDirectory+` recreate "$@"`, "-", filepath.Join(dir, "d"), "0740") @@ -200,7 +282,7 @@ func TestBashRecreateDirectory(t *testing.T) { func TestBashSafeLink(t *testing.T) { // macOS `mv` takes different arguments than GNU coreutils. - if output, err := exec.Command("mv", "--help").CombinedOutput(); err != nil { + if output, err := exec.CommandContext(t.Context(), "mv", "--help").CombinedOutput(); err != nil { t.Skip(`requires "mv" executable`) } else if !strings.Contains(string(output), "no-target-directory") { t.Skip(`requires "mv" that overwrites a directory symlink`) @@ -208,7 +290,7 @@ func TestBashSafeLink(t *testing.T) { // execute calls the bash function with args. execute := func(args ...string) (string, error) { - cmd := exec.Command("bash") + cmd := exec.CommandContext(t.Context(), "bash") cmd.Args = append(cmd.Args, "-ceu", "--", bashSafeLink+`safelink "$@"`, "-") cmd.Args = append(cmd.Args, args...) output, err := cmd.CombinedOutput() @@ -475,7 +557,7 @@ func TestStartupCommand(t *testing.T) { assert.NilError(t, os.WriteFile(file, []byte(script), 0o600)) // Expect shellcheck to be happy. - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) diff --git a/internal/postgres/databases.go b/internal/postgres/databases.go index 0d70170527..92d07a9ee8 100644 --- a/internal/postgres/databases.go +++ b/internal/postgres/databases.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/databases_test.go b/internal/postgres/databases_test.go index e025e86788..374bb450d0 100644 --- a/internal/postgres/databases_test.go +++ b/internal/postgres/databases_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/doc.go b/internal/postgres/doc.go index bd616b5916..c03bbc315b 100644 --- a/internal/postgres/doc.go +++ b/internal/postgres/doc.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/exec.go b/internal/postgres/exec.go index a846a8aa57..3042fdf828 100644 --- a/internal/postgres/exec.go +++ b/internal/postgres/exec.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/exec_test.go b/internal/postgres/exec_test.go index df9b862577..3ec94717d5 100644 --- a/internal/postgres/exec_test.go +++ b/internal/postgres/exec_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -184,7 +184,7 @@ done <<< "${databases}" assert.NilError(t, os.WriteFile(file, []byte(script), 0o600)) // Expect shellcheck to be happy. - cmd := exec.Command(shellcheck, "--enable=all", file) + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", file) output, err := cmd.CombinedOutput() assert.NilError(t, err, "%q\n%s", cmd.Args, output) diff --git a/internal/postgres/hba.go b/internal/postgres/hba.go index d9b5ce2680..444482f54f 100644 --- a/internal/postgres/hba.go +++ b/internal/postgres/hba.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,37 +6,40 @@ package postgres import ( "fmt" + "maps" + "regexp" + "slices" "strings" ) // NewHBAs returns HostBasedAuthentication records required by this package. func NewHBAs() HBAs { return HBAs{ - Mandatory: []HostBasedAuthentication{ + Mandatory: []*HostBasedAuthentication{ // The "postgres" superuser must always be able to connect locally. - *NewHBA().Local().User("postgres").Method("peer"), + NewHBA().Local().Users("postgres").Method("peer"), // The replication user must always connect over TLS using certificate // authentication. Patroni also connects to the "postgres" database // when calling `pg_rewind`. // - https://www.postgresql.org/docs/current/warm-standby.html#STREAMING-REPLICATION-AUTHENTICATION - *NewHBA().TLS().User(ReplicationUser).Method("cert").Replication(), - *NewHBA().TLS().User(ReplicationUser).Method("cert").Database("postgres"), - *NewHBA().TCP().User(ReplicationUser).Method("reject"), + NewHBA().TLS().Users(ReplicationUser).Method("cert").Replication(), + NewHBA().TLS().Users(ReplicationUser).Method("cert").Databases("postgres"), + NewHBA().TCP().Users(ReplicationUser).Method("reject"), }, - Default: []HostBasedAuthentication{ - // Allow TLS connections to any database using passwords. The "md5" - // authentication method automatically verifies passwords encrypted - // using either MD5 or SCRAM-SHA-256. + Default: []*HostBasedAuthentication{ + // Allow TLS connections to any database using passwords. Passwords are + // hashed and stored using SCRAM-SHA-256 by default. Since PostgreSQL 10, + // the "scram-sha-256" method is the preferred way to use those passwords. // - https://www.postgresql.org/docs/current/auth-password.html - *NewHBA().TLS().Method("md5"), + NewHBA().TLS().Method("scram-sha-256"), }, } } // HBAs is a pairing of HostBasedAuthentication records. -type HBAs struct{ Mandatory, Default []HostBasedAuthentication } +type HBAs struct{ Mandatory, Default []*HostBasedAuthentication } // HostBasedAuthentication represents a single record for pg_hba.conf. // - https://www.postgresql.org/docs/current/auth-pg-hba-conf.html @@ -49,10 +52,51 @@ func NewHBA() *HostBasedAuthentication { return new(HostBasedAuthentication).AllDatabases().AllNetworks().AllUsers() } -func (HostBasedAuthentication) quote(value string) string { +// hbaRegexSpecialCharacters matches a superset of the special characters in +// PostgreSQL [regular expressions] for: +// +// - [HostBasedAuthentication.quoteDatabase] +// - [HostBasedAuthentication.quoteUser] +// +// [regular expressions]: https://www.postgresql.org/docs/current/functions-matching.html#POSIX-SYNTAX-DETAILS +var hbaRegexSpecialCharacters = regexp.MustCompile(`[^\pL\pN_]`) + +func (*HostBasedAuthentication) quote(value string) string { return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` } +func (hba *HostBasedAuthentication) quoteDatabase(name string) string { + // Since PostgreSQL 16, a quoted string beginning with slash U+002F is + // interpreted as a regular expression. Express these names as a Postgres + // regex that exactly matches the entire name. + if len(name) > 0 && name[0] == '/' { + name = "/^" + + hbaRegexSpecialCharacters.ReplaceAllStringFunc(name, + func(match string) string { return "[" + match + "]" }) + + "$" + } + + // Quotes indicate the value is NOT a keyword (all, sameuser, etc.) + // and NOT to be expanded as a filename (at sign U+0040). + return hba.quote(name) +} + +func (hba *HostBasedAuthentication) quoteUser(name string) string { + // Since PostgreSQL 16, a quoted string beginning with slash U+002F is + // interpreted as a regular expression. Express these names as a Postgres + // regex that exactly matches the entire name. + if len(name) > 0 && name[0] == '/' { + name = "/^" + + hbaRegexSpecialCharacters.ReplaceAllStringFunc(name, + func(match string) string { return "[" + match + "]" }) + + "$" + } + + // Quotes indicate the value is NOT a keyword (all), NOT a group (plus U+002B), + // and NOT to be expanded as a filename (at sign U+0040). + return hba.quote(name) +} + // AllDatabases makes hba match connections made to any database. func (hba *HostBasedAuthentication) AllDatabases() *HostBasedAuthentication { hba.database = "all" @@ -71,9 +115,12 @@ func (hba *HostBasedAuthentication) AllUsers() *HostBasedAuthentication { return hba } -// Database makes hba match connections made to a specific database. -func (hba *HostBasedAuthentication) Database(name string) *HostBasedAuthentication { - hba.database = hba.quote(name) +// Databases makes hba match connections made to specific databases. +func (hba *HostBasedAuthentication) Databases(name string, names ...string) *HostBasedAuthentication { + hba.database = hba.quoteDatabase(name) + for _, n := range names { + hba.database += "," + hba.quoteDatabase(n) + } return hba } @@ -85,7 +132,12 @@ func (hba *HostBasedAuthentication) Local() *HostBasedAuthentication { // Method specifies the authentication method to use when a connection matches hba. func (hba *HostBasedAuthentication) Method(name string) *HostBasedAuthentication { - hba.method = name + hba.method = hba.quote(name) + return hba +} + +func (hba *HostBasedAuthentication) Origin(name string) *HostBasedAuthentication { + hba.origin = hba.quote(name) return hba } @@ -104,8 +156,8 @@ func (hba *HostBasedAuthentication) NoSSL() *HostBasedAuthentication { // Options specifies any options for the authentication method. func (hba *HostBasedAuthentication) Options(opts map[string]string) *HostBasedAuthentication { hba.options = "" - for k, v := range opts { - hba.options = fmt.Sprintf("%s %s=%s", hba.options, k, hba.quote(v)) + for _, k := range slices.Sorted(maps.Keys(opts)) { + hba.options = fmt.Sprintf("%s %s=%s", hba.options, hba.quote(k), hba.quote(opts[k])) } return hba } @@ -116,12 +168,6 @@ func (hba *HostBasedAuthentication) Replication() *HostBasedAuthentication { return hba } -// Role makes hba match connections by users that are members of a specific role. -func (hba *HostBasedAuthentication) Role(name string) *HostBasedAuthentication { - hba.user = "+" + hba.quote(name) - return hba -} - // SameNetwork makes hba match connection attempts from IP addresses in any // subnet to which the server is directly connected. func (hba *HostBasedAuthentication) SameNetwork() *HostBasedAuthentication { @@ -141,14 +187,17 @@ func (hba *HostBasedAuthentication) TCP() *HostBasedAuthentication { return hba } -// User makes hba match connections by a specific user. -func (hba *HostBasedAuthentication) User(name string) *HostBasedAuthentication { - hba.user = hba.quote(name) +// Users makes hba match connections by specific users. +func (hba *HostBasedAuthentication) Users(name string, names ...string) *HostBasedAuthentication { + hba.user = hba.quoteUser(name) + for _, n := range names { + hba.user += "," + hba.quoteUser(n) + } return hba } // String returns hba formatted for the pg_hba.conf file without a newline. -func (hba HostBasedAuthentication) String() string { +func (hba *HostBasedAuthentication) String() string { if hba.origin == "local" { return strings.TrimSpace(fmt.Sprintf("local %s %s %s %s", hba.database, hba.user, hba.method, hba.options)) @@ -157,3 +206,43 @@ func (hba HostBasedAuthentication) String() string { return strings.TrimSpace(fmt.Sprintf("%s %s %s %s %s %s", hba.origin, hba.database, hba.user, hba.address, hba.method, hba.options)) } + +// OrderedHBAs is an append-only sequence of pg_hba.conf lines. +type OrderedHBAs struct { + records []string +} + +// Append renders and adds pg_hba.conf lines to o. Nil pointers are ignored. +func (o *OrderedHBAs) Append(hbas ...*HostBasedAuthentication) { + for _, hba := range hbas { + if hba != nil { + o.records = append(o.records, hba.String()) + } + } +} + +// AppendUnstructured trims and adds unvalidated pg_hba.conf lines to o. +// Empty lines and lines that are entirely control characters are omitted. +func (o *OrderedHBAs) AppendUnstructured(hbas ...string) { + for _, hba := range hbas { + hba = strings.TrimFunc(hba, func(r rune) bool { + // control characters, space, and backslash + return r > '~' || r < '!' || r == '\\' + }) + + // NOTE: Skipping "include" directives here is a security measure. + if len(hba) > 0 && !strings.HasPrefix(hba, "include") { + o.records = append(o.records, hba) + } + } +} + +// AsStrings returns a copy of o as a slice. +func (o *OrderedHBAs) AsStrings() []string { + return slices.Clone(o.records) +} + +// Length returns the number of records in o. +func (o *OrderedHBAs) Length() int { + return len(o.records) +} diff --git a/internal/postgres/hba_test.go b/internal/postgres/hba_test.go index 9744479fdd..7ee4a4dece 100644 --- a/internal/postgres/hba_test.go +++ b/internal/postgres/hba_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -14,7 +14,7 @@ import ( ) func TestNewHBAs(t *testing.T) { - matches := func(actual []HostBasedAuthentication, expected string) cmp.Comparison { + matches := func(actual []*HostBasedAuthentication, expected string) cmp.Comparison { printed := make([]string, len(actual)) for i := range actual { printed[i] = actual[i].String() @@ -30,33 +30,133 @@ func TestNewHBAs(t *testing.T) { hba := NewHBAs() assert.Assert(t, matches(hba.Mandatory, ` -local all "postgres" peer -hostssl replication "_crunchyrepl" all cert -hostssl "postgres" "_crunchyrepl" all cert -host all "_crunchyrepl" all reject +local all "postgres" "peer" +hostssl replication "_crunchyrepl" all "cert" +hostssl "postgres" "_crunchyrepl" all "cert" +host all "_crunchyrepl" all "reject" `)) assert.Assert(t, matches(hba.Default, ` -hostssl all all all md5 +hostssl all all all "scram-sha-256" `)) } func TestHostBasedAuthentication(t *testing.T) { - assert.Equal(t, `local all "postgres" peer`, - NewHBA().Local().User("postgres").Method("peer").String()) + assert.Equal(t, `local all "postgres","pgo" "peer"`, + NewHBA().Local().Users("postgres", "pgo").Method("peer").String()) - assert.Equal(t, `host all all "::1/128" trust`, + assert.Equal(t, `host all all "::1/128" "trust"`, NewHBA().TCP().Network("::1/128").Method("trust").String()) - assert.Equal(t, `host replication "KD6-3.7" samenet scram-sha-256`, + assert.Equal(t, `host replication "KD6-3.7" samenet "scram-sha-256"`, NewHBA().TCP().SameNetwork().Replication(). - User("KD6-3.7").Method("scram-sha-256"). + Users("KD6-3.7").Method("scram-sha-256"). String()) - assert.Equal(t, `hostssl "data" +"admin" all md5 clientcert="verify-ca"`, - NewHBA().TLS().Database("data").Role("admin"). + assert.Equal(t, `hostssl "data","bits" all all "md5" "clientcert"="verify-ca"`, + NewHBA().TLS().Databases("data", "bits"). Method("md5").Options(map[string]string{"clientcert": "verify-ca"}). String()) - assert.Equal(t, `hostnossl all all all reject`, + assert.Equal(t, `hostnossl all all all "reject"`, NewHBA().NoSSL().Method("reject").String()) + + t.Run("OptionsSorted", func(t *testing.T) { + assert.Equal(t, `hostssl all all all "ldap" "ldapbasedn"="dc=example,dc=org" "ldapserver"="example.org"`, + NewHBA().TLS().Method("ldap").Options(map[string]string{ + "ldapserver": "example.org", + "ldapbasedn": "dc=example,dc=org", + }).String()) + }) + + t.Run("SpecialCharactersEscaped", func(t *testing.T) { + // Databases; slash U+002F triggers regex escaping; regex characters themselves do not + assert.Equal(t, `local "/^[/]asdf_[+][?]1234$","/^[/][*][$]$","+*$" all`, + NewHBA().Local().Databases(`/asdf_+?1234`, `/*$`, `+*$`).String()) + + // Users; slash U+002F triggers regex escaping; regex characters themselves do not + assert.Equal(t, `local all "/^[/]asdf_[+][?]1234$","/^[/][*][$]$","+*$"`, + NewHBA().Local().Users(`/asdf_+?1234`, `/*$`, `+*$`).String()) + }) +} + +func TestOrderedHBAs(t *testing.T) { + ordered := new(OrderedHBAs) + + // The zero value is empty. + assert.Equal(t, ordered.Length(), 0) + assert.Assert(t, cmp.Len(ordered.AsStrings(), 0)) + + // Append can be called without arguments. + ordered.Append() + ordered.AppendUnstructured() + assert.Assert(t, cmp.Len(ordered.AsStrings(), 0)) + + // Append adds to the end of the slice. + ordered.Append(NewHBA()) + assert.Equal(t, ordered.Length(), 1) + assert.DeepEqual(t, ordered.AsStrings(), []string{ + `all all all`, + }) + + // AppendUnstructured adds to the end of the slice. + ordered.AppendUnstructured("could be anything, really") + assert.Equal(t, ordered.Length(), 2) + assert.DeepEqual(t, ordered.AsStrings(), []string{ + `all all all`, + `could be anything, really`, + }) + + // Append and AppendUnstructured do not have a separate order. + ordered.Append(NewHBA().Users("zoro")) + assert.Equal(t, ordered.Length(), 3) + assert.DeepEqual(t, ordered.AsStrings(), []string{ + `all all all`, + `could be anything, really`, + `all "zoro" all`, + }) + + t.Run("NilPointersIgnored", func(t *testing.T) { + rules := new(OrderedHBAs) + rules.Append( + NewHBA(), nil, + NewHBA(), nil, + ) + assert.DeepEqual(t, rules.AsStrings(), []string{ + `all all all`, + `all all all`, + }) + }) + + // See [internal/testing/validation.TestPostgresAuthenticationRules] + t.Run("NoInclude", func(t *testing.T) { + rules := new(OrderedHBAs) + rules.AppendUnstructured( + `one`, + `include "/etc/passwd"`, + ` include_dir /tmp`, + `include_if_exists postgresql.auto.conf`, + `two`, + ) + assert.DeepEqual(t, rules.AsStrings(), []string{ + `one`, + `two`, + }) + }) + + t.Run("SpecialCharactersStripped", func(t *testing.T) { + rules := new(OrderedHBAs) + rules.AppendUnstructured( + " \n\t things \n\n\n", + `with # comment`, + " \n\t \\\\ \f", // entirely special characters + `trailing slashes \\\`, + "multiple \\\n lines okay", + ) + assert.DeepEqual(t, rules.AsStrings(), []string{ + `things`, + `with # comment`, + `trailing slashes`, + "multiple \\\n lines okay", + }) + }) } diff --git a/internal/postgres/huge_pages.go b/internal/postgres/huge_pages.go index ee13c0d11b..b38120bafd 100644 --- a/internal/postgres/huge_pages.go +++ b/internal/postgres/huge_pages.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/huge_pages_test.go b/internal/postgres/huge_pages_test.go index 58a6a6aa57..9b9f12172f 100644 --- a/internal/postgres/huge_pages_test.go +++ b/internal/postgres/huge_pages_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/iana.go b/internal/postgres/iana.go index 4392b549f1..395ee1d1e8 100644 --- a/internal/postgres/iana.go +++ b/internal/postgres/iana.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/parameters.go b/internal/postgres/parameters.go index 434d9fd1dd..6fb7b0d2f3 100644 --- a/internal/postgres/parameters.go +++ b/internal/postgres/parameters.go @@ -1,10 +1,13 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package postgres import ( + "fmt" + "maps" + "slices" "strings" ) @@ -45,6 +48,16 @@ func NewParameters() Parameters { // - https://www.postgresql.org/docs/current/auth-password.html parameters.Default.Add("password_encryption", "scram-sha-256") + // Pod "securityContext.fsGroup" ensures processes and filesystems agree on a GID; + // use the same permissions for group and owner. + // This allows every process in the pod to read Postgres log files. + // + // S_IRUSR, S_IWUSR: (0600) enable owner read and write permissions + // S_IRGRP, S_IWGRP: (0060) enable group read and write permissions. + // + // PostgreSQL must be reloaded when changing this value. + parameters.Mandatory.Add("log_file_mode", "0660") + return parameters } @@ -65,18 +78,22 @@ func NewParameterSet() *ParameterSet { } // AsMap returns a copy of ps as a map. -func (ps ParameterSet) AsMap() map[string]string { - out := make(map[string]string, len(ps.values)) - for name, value := range ps.values { - out[name] = value +func (ps *ParameterSet) AsMap() map[string]string { + if ps == nil { + return nil } - return out + + return maps.Clone(ps.values) } // DeepCopy returns a copy of ps. -func (ps *ParameterSet) DeepCopy() (out *ParameterSet) { +func (ps *ParameterSet) DeepCopy() *ParameterSet { + if ps == nil { + return nil + } + return &ParameterSet{ - values: ps.AsMap(), + values: maps.Clone(ps.values), } } @@ -102,25 +119,43 @@ func (ps *ParameterSet) AppendToList(name string, value ...string) { } // Get returns the value of parameter name and whether or not it was present in ps. -func (ps ParameterSet) Get(name string) (string, bool) { +func (ps *ParameterSet) Get(name string) (string, bool) { value, ok := ps.values[ps.normalize(name)] return value, ok } // Has returns whether or not parameter name is present in ps. -func (ps ParameterSet) Has(name string) bool { +func (ps *ParameterSet) Has(name string) bool { _, ok := ps.Get(name) return ok } -func (ParameterSet) normalize(name string) string { +func (*ParameterSet) normalize(name string) string { // All parameter names are case-insensitive. // -- https://www.postgresql.org/docs/current/config-setting.html return strings.ToLower(name) } // Value returns empty string or the value of parameter name if it is present in ps. -func (ps ParameterSet) Value(name string) string { +func (ps *ParameterSet) Value(name string) string { value, _ := ps.Get(name) return value } + +func (ps *ParameterSet) String() string { + keys := make([]string, 0, len(ps.values)) + for k := range ps.values { + keys = append(keys, k) + } + + slices.Sort(keys) + + var b strings.Builder + for _, k := range keys { + _, _ = fmt.Fprintf(&b, "%s = '%s'\n", k, escapeParameterQuotes(ps.values[k])) + } + return b.String() +} + +// escapeParameterQuotes is used by [ParameterSet.String]. +var escapeParameterQuotes = strings.NewReplacer(`'`, `''`).Replace diff --git a/internal/postgres/parameters_test.go b/internal/postgres/parameters_test.go index c6228d7958..ad8c6e90c9 100644 --- a/internal/postgres/parameters_test.go +++ b/internal/postgres/parameters_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -14,6 +14,8 @@ func TestNewParameters(t *testing.T) { parameters := NewParameters() assert.DeepEqual(t, parameters.Mandatory.AsMap(), map[string]string{ + "log_file_mode": "0660", + "ssl": "on", "ssl_ca_file": "/pgconf/tls/ca.crt", "ssl_cert_file": "/pgconf/tls/tls.crt", @@ -31,6 +33,16 @@ func TestNewParameters(t *testing.T) { } func TestParameterSet(t *testing.T) { + t.Run("NilAsMap", func(t *testing.T) { + m := (*ParameterSet)(nil).AsMap() + assert.Assert(t, m == nil) + }) + + t.Run("NilDeepCopy", func(t *testing.T) { + ps := (*ParameterSet)(nil).DeepCopy() + assert.Assert(t, ps == nil) + }) + ps := NewParameterSet() ps.Add("x", "y") @@ -56,6 +68,10 @@ func TestParameterSet(t *testing.T) { ps2.Add("x", "n") assert.Assert(t, ps2.Value("x") != ps.Value("x")) + + assert.DeepEqual(t, ps.String(), ``+ + `abc = 'j''l'`+"\n"+ + `x = 'z'`+"\n") } func TestParameterSetAppendToList(t *testing.T) { diff --git a/internal/postgres/password/doc.go b/internal/postgres/password/doc.go index eef7ed7db2..f3572a4588 100644 --- a/internal/postgres/password/doc.go +++ b/internal/postgres/password/doc.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/password/md5.go b/internal/postgres/password/md5.go index 884dfb655e..55cc43f5cb 100644 --- a/internal/postgres/password/md5.go +++ b/internal/postgres/password/md5.go @@ -1,11 +1,10 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package password import ( - // #nosec G501 "crypto/md5" "errors" diff --git a/internal/postgres/password/md5_test.go b/internal/postgres/password/md5_test.go index 80cb7742d6..d6f564eab8 100644 --- a/internal/postgres/password/md5_test.go +++ b/internal/postgres/password/md5_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/password/password.go b/internal/postgres/password/password.go index 337282cc74..5f5894f535 100644 --- a/internal/postgres/password/password.go +++ b/internal/postgres/password/password.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/password/password_test.go b/internal/postgres/password/password_test.go index 3401dec4ac..bc4ddc883d 100644 --- a/internal/postgres/password/password_test.go +++ b/internal/postgres/password/password_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/password/scram.go b/internal/postgres/password/scram.go index 8264cd87a0..90eb2a54ad 100644 --- a/internal/postgres/password/scram.go +++ b/internal/postgres/password/scram.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -138,7 +138,7 @@ func (s *SCRAMPassword) isASCII() bool { // iterate through each character of the plaintext password and determine if // it is ASCII. if it is not ASCII, exit early // per research, this loop is optimized to be fast for searching - for i := 0; i < len(s.password); i++ { + for i := range len(s.password) { if s.password[i] > unicode.MaxASCII { return false } diff --git a/internal/postgres/password/scram_test.go b/internal/postgres/password/scram_test.go index 0552e519b7..2c5cd2089d 100644 --- a/internal/postgres/password/scram_test.go +++ b/internal/postgres/password/scram_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/postgres/reconcile.go b/internal/postgres/reconcile.go index 344f91dd9f..81c6cc31fa 100644 --- a/internal/postgres/reconcile.go +++ b/internal/postgres/reconcile.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -8,7 +8,6 @@ import ( "context" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" "github.com/crunchydata/postgres-operator/internal/config" "github.com/crunchydata/postgres-operator/internal/feature" @@ -17,11 +16,6 @@ import ( "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) -var ( - oneMillicore = resource.MustParse("1m") - oneMebibyte = resource.MustParse("1Mi") -) - // DataVolumeMount returns the name and mount path of the PostgreSQL data volume. func DataVolumeMount() corev1.VolumeMount { return corev1.VolumeMount{Name: "postgres-data", MountPath: dataMountPath} @@ -32,6 +26,11 @@ func TablespaceVolumeMount(tablespaceName string) corev1.VolumeMount { return corev1.VolumeMount{Name: "tablespace-" + tablespaceName, MountPath: tablespaceMountPath + "/" + tablespaceName} } +// TempVolumeMount returns the name and mount path of the ephemeral volume. +func TempVolumeMount() corev1.VolumeMount { + return corev1.VolumeMount{Name: "postgres-temp", MountPath: tmpMountPath} +} + // WALVolumeMount returns the name and mount path of the PostgreSQL WAL volume. func WALVolumeMount() corev1.VolumeMount { return corev1.VolumeMount{Name: "postgres-wal", MountPath: walMountPath} @@ -46,8 +45,8 @@ func DownwardAPIVolumeMount() corev1.VolumeMount { } } -// AdditionalConfigVolumeMount returns the name and mount path of the additional config files. -func AdditionalConfigVolumeMount() corev1.VolumeMount { +// ConfigVolumeMount returns the name and mount path of PostgreSQL config files. +func ConfigVolumeMount() corev1.VolumeMount { return corev1.VolumeMount{ Name: "postgres-config", MountPath: configMountPath, @@ -63,7 +62,7 @@ func InstancePod(ctx context.Context, inClusterCertificates, inClientCertificates *corev1.SecretProjection, inDataVolume, inWALVolume *corev1.PersistentVolumeClaim, inTablespaceVolumes []*corev1.PersistentVolumeClaim, - outInstancePod *corev1.PodSpec, + outInstancePod *corev1.PodTemplateSpec, ) { certVolumeMount := corev1.VolumeMount{ Name: naming.CertVolume, @@ -111,28 +110,24 @@ func InstancePod(ctx context.Context, ResourceFieldRef: &corev1.ResourceFieldSelector{ ContainerName: naming.ContainerDatabase, Resource: "limits.cpu", - Divisor: oneMillicore, }, }, { Path: "cpu_request", ResourceFieldRef: &corev1.ResourceFieldSelector{ ContainerName: naming.ContainerDatabase, Resource: "requests.cpu", - Divisor: oneMillicore, }, }, { Path: "mem_limit", ResourceFieldRef: &corev1.ResourceFieldSelector{ ContainerName: naming.ContainerDatabase, Resource: "limits.memory", - Divisor: oneMebibyte, }, }, { Path: "mem_request", ResourceFieldRef: &corev1.ResourceFieldSelector{ ContainerName: naming.ContainerDatabase, Resource: "requests.memory", - Divisor: oneMebibyte, }, }, { Path: "labels", @@ -207,7 +202,7 @@ func InstancePod(ctx context.Context, VolumeMounts: []corev1.VolumeMount{certVolumeMount, dataVolumeMount}, } - outInstancePod.Volumes = []corev1.Volume{ + outInstancePod.Spec.Volumes = []corev1.Volume{ certVolume, dataVolume, downwardAPIVolume, @@ -227,19 +222,19 @@ func InstancePod(ctx context.Context, }, }, } - outInstancePod.Volumes = append(outInstancePod.Volumes, tablespaceVolume) + outInstancePod.Spec.Volumes = append(outInstancePod.Spec.Volumes, tablespaceVolume) container.VolumeMounts = append(container.VolumeMounts, tablespaceVolumeMount) startup.VolumeMounts = append(startup.VolumeMounts, tablespaceVolumeMount) } - if len(inCluster.Spec.Config.Files) != 0 { - additionalConfigVolumeMount := AdditionalConfigVolumeMount() + if inCluster.Spec.Config != nil && len(inCluster.Spec.Config.Files) != 0 { + additionalConfigVolumeMount := ConfigVolumeMount() additionalConfigVolume := corev1.Volume{Name: additionalConfigVolumeMount.Name} additionalConfigVolume.Projected = &corev1.ProjectedVolumeSource{ Sources: append([]corev1.VolumeProjection{}, inCluster.Spec.Config.Files...), } container.VolumeMounts = append(container.VolumeMounts, additionalConfigVolumeMount) - outInstancePod.Volumes = append(outInstancePod.Volumes, additionalConfigVolume) + outInstancePod.Spec.Volumes = append(outInstancePod.Spec.Volumes, additionalConfigVolume) } // Mount the WAL PVC whenever it exists. The startup command will move WAL @@ -258,32 +253,50 @@ func InstancePod(ctx context.Context, container.VolumeMounts = append(container.VolumeMounts, walVolumeMount) startup.VolumeMounts = append(startup.VolumeMounts, walVolumeMount) - outInstancePod.Volumes = append(outInstancePod.Volumes, walVolume) + outInstancePod.Spec.Volumes = append(outInstancePod.Spec.Volumes, walVolume) + } + + // Mount an ephemeral volume, if specified. + if inInstanceSpec.Volumes != nil && inInstanceSpec.Volumes.Temp != nil { + tmpVolumeMount := TempVolumeMount() + tmpVolume := corev1.Volume{Name: tmpVolumeMount.Name} + tmpVolume.Ephemeral = &corev1.EphemeralVolumeSource{ + VolumeClaimTemplate: &corev1.PersistentVolumeClaimTemplate{ + Spec: inInstanceSpec.Volumes.Temp.AsPersistentVolumeClaimSpec(), + }, + } + + // Create the PVC with the same labels and annotations as the pod. + tmpVolume.Ephemeral.VolumeClaimTemplate.Annotations = outInstancePod.Annotations + tmpVolume.Ephemeral.VolumeClaimTemplate.Labels = outInstancePod.Labels + + container.VolumeMounts = append(container.VolumeMounts, tmpVolumeMount) + outInstancePod.Spec.Volumes = append(outInstancePod.Spec.Volumes, tmpVolume) } - outInstancePod.Containers = []corev1.Container{container, reloader} + outInstancePod.Spec.Containers = []corev1.Container{container, reloader} // If the InstanceSidecars feature gate is enabled and instance sidecars are // defined, add the defined container to the Pod. if feature.Enabled(ctx, feature.InstanceSidecars) && inInstanceSpec.Containers != nil { - outInstancePod.Containers = append(outInstancePod.Containers, inInstanceSpec.Containers...) + outInstancePod.Spec.Containers = append(outInstancePod.Spec.Containers, inInstanceSpec.Containers...) } - outInstancePod.InitContainers = []corev1.Container{startup} + outInstancePod.Spec.InitContainers = []corev1.Container{startup} } // PodSecurityContext returns a v1.PodSecurityContext for cluster that can write // to PersistentVolumes. func PodSecurityContext(cluster *v1beta1.PostgresCluster) *corev1.PodSecurityContext { - podSecurityContext := initialize.PodSecurityContext() + psc := initialize.PodSecurityContext() // Use the specified supplementary groups except for root. The CRD has // similar validation, but we should never emit a PodSpec with that group. // - https://docs.k8s.io/concepts/security/pod-security-standards/ for i := range cluster.Spec.SupplementalGroups { if gid := cluster.Spec.SupplementalGroups[i]; gid > 0 { - podSecurityContext.SupplementalGroups = append(podSecurityContext.SupplementalGroups, gid) + psc.SupplementalGroups = append(psc.SupplementalGroups, gid) } } @@ -293,9 +306,9 @@ func PodSecurityContext(cluster *v1beta1.PostgresCluster) *corev1.PodSecurityCon // - https://cloud.redhat.com/blog/a-guide-to-openshift-and-uids // - https://docs.k8s.io/tasks/configure-pod-container/security-context/ // - https://docs.openshift.com/container-platform/4.8/authentication/managing-security-context-constraints.html - if cluster.Spec.OpenShift == nil || !*cluster.Spec.OpenShift { - podSecurityContext.FSGroup = initialize.Int64(26) + if !initialize.FromPointer(cluster.Spec.OpenShift) { + psc.FSGroup = initialize.Int64(26) } - return podSecurityContext + return psc } diff --git a/internal/postgres/reconcile_test.go b/internal/postgres/reconcile_test.go index 138b5c7b3e..c001ce890b 100644 --- a/internal/postgres/reconcile_test.go +++ b/internal/postgres/reconcile_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -16,6 +16,7 @@ import ( "github.com/crunchydata/postgres-operator/internal/initialize" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) @@ -114,11 +115,11 @@ func TestInstancePod(t *testing.T) { } // without WAL volume nor WAL volume spec - pod := new(corev1.PodSpec) + pod := new(corev1.PodTemplateSpec) InstancePod(ctx, cluster, instance, serverSecretProjection, clientSecretProjection, dataVolume, nil, nil, pod) - assert.Assert(t, cmp.MarshalMatches(pod, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec, ` containers: - env: - name: PGDATA @@ -174,6 +175,25 @@ containers: TOKEN=$(cat ${SERVICEACCOUNT}/token) CACERT=${SERVICEACCOUNT}/ca.crt + # Manage autogrow annotation. + # Return size in Mebibytes. + manageAutogrowAnnotation() { + local volume=$1 + + size=$(df --human-readable --block-size=M /"${volume}" | awk 'FNR == 2 {print $2}') + use=$(df --human-readable /"${volume}" | awk 'FNR == 2 {print $5}') + sizeInt="${size//M/}" + # Use the sed punctuation class, because the shell will not accept the percent sign in an expansion. + useInt=$(echo $use | sed 's/[[:punct:]]//g') + triggerExpansion="$((useInt > 75))" + if [ $triggerExpansion -eq 1 ]; then + newSize="$(((sizeInt / 2)+sizeInt))" + newSizeMi="${newSize}Mi" + d='[{"op": "add", "path": "/metadata/annotations/suggested-'"${volume}"'-pvc-size", "value": "'"$newSizeMi"'"}]' + curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -XPATCH "${APISERVER}/api/v1/namespaces/${NAMESPACE}/pods/${HOSTNAME}?fieldManager=kubectl-annotate" -H "Content-Type: application/json-patch+json" --data "$d" + fi + } + declare -r directory="/pgconf/tls" exec {fd}<> <(:||:) while read -r -t 5 -u "${fd}" ||:; do @@ -186,20 +206,8 @@ containers: stat --format='Loaded certificates dated %y' "${directory}" fi - # Manage autogrow annotation. - # Return size in Mebibytes. - size=$(df --human-readable --block-size=M /pgdata | awk 'FNR == 2 {print $2}') - use=$(df --human-readable /pgdata | awk 'FNR == 2 {print $5}') - sizeInt="${size//M/}" - # Use the sed punctuation class, because the shell will not accept the percent sign in an expansion. - useInt=$(echo $use | sed 's/[[:punct:]]//g') - triggerExpansion="$((useInt > 75))" - if [ $triggerExpansion -eq 1 ]; then - newSize="$(((sizeInt / 2)+sizeInt))" - newSizeMi="${newSize}Mi" - d='[{"op": "add", "path": "/metadata/annotations/suggested-pgdata-pvc-size", "value": "'"$newSizeMi"'"}]' - curl --cacert ${CACERT} --header "Authorization: Bearer ${TOKEN}" -XPATCH "${APISERVER}/api/v1/namespaces/${NAMESPACE}/pods/${HOSTNAME}?fieldManager=kubectl-annotate" -H "Content-Type: application/json-patch+json" --data "$d" - fi + # manage autogrow annotation for the pgData volume + manageAutogrowAnnotation "pgdata" done }; export -f monitor; exec -a "$0" bash -ceu monitor - replication-cert-copy @@ -230,7 +238,7 @@ initContainers: - -ceu - -- - |- - declare -r expected_major_version="$1" pgwal_directory="$2" pgbrLog_directory="$3" + declare -r expected_major_version="$1" pgwal_directory="$2" permissions() { while [[ -n "$1" ]]; do set "${1%/*}" "$@"; done; shift; stat -Lc '%A %4u %4g %n' "$@"; } halt() { local rc=$?; >&2 echo "$@"; exit "${rc/#0/1}"; } results() { printf '::postgres-operator: %s::%s\n' "$@"; } @@ -262,14 +270,17 @@ initContainers: [[ -d "${bootstrap_dir}" ]] && results 'bootstrap directory' "${bootstrap_dir}" [[ -d "${bootstrap_dir}" ]] && postgres_data_directory="${bootstrap_dir}" if [[ ! -e "${postgres_data_directory}" || -O "${postgres_data_directory}" ]]; then - install --directory --mode=0700 "${postgres_data_directory}" + install --directory --mode=0750 "${postgres_data_directory}" elif [[ -w "${postgres_data_directory}" && -g "${postgres_data_directory}" ]]; then - recreate "${postgres_data_directory}" '0700' + recreate "${postgres_data_directory}" '0750' else (halt Permissions!); fi || halt "$(permissions "${postgres_data_directory}" ||:)" - results 'pgBackRest log directory' "${pgbrLog_directory}" - install --directory --mode=0775 "${pgbrLog_directory}" || - halt "$(permissions "${pgbrLog_directory}" ||:)" + (mkdir -p '/pgdata/pgbackrest/log' && { chmod 0775 '/pgdata/pgbackrest/log' '/pgdata/pgbackrest' || :; }) || + halt "$(permissions /pgdata/pgbackrest/log ||:)" + (mkdir -p '/pgdata/patroni/log' && { chmod 0775 '/pgdata/patroni/log' '/pgdata/patroni' || :; }) || + halt "$(permissions /pgdata/patroni/log ||:)" + (mkdir -p '/pgdata/logs/postgres' && { chmod 0775 '/pgdata/logs/postgres' '/pgdata/logs' || :; }) || + halt "$(permissions /pgdata/logs/postgres ||:)" install -D --mode=0600 -t "/tmp/replication" "/pgconf/tls/replication"/{tls.crt,tls.key,ca.crt} @@ -281,11 +292,9 @@ initContainers: touch "${postgres_data_directory}/postgresql.conf" safelink "${pgwal_directory}" "${postgres_data_directory}/pg_wal" results 'wal directory' "$(realpath "${postgres_data_directory}/pg_wal" ||:)" - rm -f "${postgres_data_directory}/recovery.signal" - startup - "11" - /pgdata/pg11_wal - - /pgdata/pgbackrest/log env: - name: PGDATA value: /pgdata/pg11 @@ -349,22 +358,22 @@ volumes: - path: cpu_limit resourceFieldRef: containerName: database - divisor: 1m + divisor: "0" resource: limits.cpu - path: cpu_request resourceFieldRef: containerName: database - divisor: 1m + divisor: "0" resource: requests.cpu - path: mem_limit resourceFieldRef: containerName: database - divisor: 1Mi + divisor: "0" resource: limits.memory - path: mem_request resourceFieldRef: containerName: database - divisor: 1Mi + divisor: "0" resource: requests.memory - fieldRef: apiVersion: v1 @@ -381,15 +390,15 @@ volumes: walVolume := new(corev1.PersistentVolumeClaim) walVolume.Name = "walvol" - pod := new(corev1.PodSpec) + pod := new(corev1.PodTemplateSpec) InstancePod(ctx, cluster, instance, serverSecretProjection, clientSecretProjection, dataVolume, walVolume, nil, pod) - assert.Assert(t, len(pod.Containers) > 0) - assert.Assert(t, len(pod.InitContainers) > 0) + assert.Assert(t, len(pod.Spec.Containers) > 0) + assert.Assert(t, len(pod.Spec.InitContainers) > 0) // Container has all mountPaths, including downwardAPI - assert.Assert(t, cmp.MarshalMatches(pod.Containers[0].VolumeMounts, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.Containers[0].VolumeMounts, ` - mountPath: /pgconf/tls name: cert-volume readOnly: true @@ -399,19 +408,19 @@ volumes: name: database-containerinfo readOnly: true - mountPath: /pgwal - name: postgres-wal`), "expected WAL and downwardAPI mounts in %q container", pod.Containers[0].Name) + name: postgres-wal`), "expected WAL and downwardAPI mounts in %q container", pod.Spec.Containers[0].Name) // InitContainer has all mountPaths, except downwardAPI - assert.Assert(t, cmp.MarshalMatches(pod.InitContainers[0].VolumeMounts, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.InitContainers[0].VolumeMounts, ` - mountPath: /pgconf/tls name: cert-volume readOnly: true - mountPath: /pgdata name: postgres-data - mountPath: /pgwal - name: postgres-wal`), "expected WAL mount, no downwardAPI mount in %q container", pod.InitContainers[0].Name) + name: postgres-wal`), "expected WAL mount, no downwardAPI mount in %q container", pod.Spec.InitContainers[0].Name) - assert.Assert(t, cmp.MarshalMatches(pod.Volumes, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.Volumes, ` - name: cert-volume projected: defaultMode: 384 @@ -440,22 +449,22 @@ volumes: - path: cpu_limit resourceFieldRef: containerName: database - divisor: 1m + divisor: "0" resource: limits.cpu - path: cpu_request resourceFieldRef: containerName: database - divisor: 1m + divisor: "0" resource: requests.cpu - path: mem_limit resourceFieldRef: containerName: database - divisor: 1Mi + divisor: "0" resource: limits.memory - path: mem_request resourceFieldRef: containerName: database - divisor: 1Mi + divisor: "0" resource: requests.memory - fieldRef: apiVersion: v1 @@ -472,32 +481,26 @@ volumes: `), "expected WAL volume") // Startup moves WAL files to data volume. - assert.DeepEqual(t, pod.InitContainers[0].Command[4:], - []string{"startup", "11", "/pgdata/pg11_wal", "/pgdata/pgbackrest/log"}) + assert.DeepEqual(t, pod.Spec.InitContainers[0].Command[4:], + []string{"startup", "11", "/pgdata/pg11_wal"}) }) t.Run("WithAdditionalConfigFiles", func(t *testing.T) { clusterWithConfig := cluster.DeepCopy() - clusterWithConfig.Spec.Config.Files = []corev1.VolumeProjection{ - { - Secret: &corev1.SecretProjection{ - LocalObjectReference: corev1.LocalObjectReference{ - Name: "keytab", - }, - }, - }, - } + require.UnmarshalInto(t, &clusterWithConfig.Spec.Config, `{ + files: [{ secret: { name: keytab } }], + }`) - pod := new(corev1.PodSpec) + pod := new(corev1.PodTemplateSpec) InstancePod(ctx, clusterWithConfig, instance, serverSecretProjection, clientSecretProjection, dataVolume, nil, nil, pod) - assert.Assert(t, len(pod.Containers) > 0) - assert.Assert(t, len(pod.InitContainers) > 0) + assert.Assert(t, len(pod.Spec.Containers) > 0) + assert.Assert(t, len(pod.Spec.InitContainers) > 0) // Container has all mountPaths, including downwardAPI, // and the postgres-config - assert.Assert(t, cmp.MarshalMatches(pod.Containers[0].VolumeMounts, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.Containers[0].VolumeMounts, ` - mountPath: /pgconf/tls name: cert-volume readOnly: true @@ -508,15 +511,15 @@ volumes: readOnly: true - mountPath: /etc/postgres name: postgres-config - readOnly: true`), "expected WAL and downwardAPI mounts in %q container", pod.Containers[0].Name) + readOnly: true`), "expected WAL and downwardAPI mounts in %q container", pod.Spec.Containers[0].Name) // InitContainer has all mountPaths, except downwardAPI and additionalConfig - assert.Assert(t, cmp.MarshalMatches(pod.InitContainers[0].VolumeMounts, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.InitContainers[0].VolumeMounts, ` - mountPath: /pgconf/tls name: cert-volume readOnly: true - mountPath: /pgdata - name: postgres-data`), "expected WAL mount, no downwardAPI mount in %q container", pod.InitContainers[0].Name) + name: postgres-data`), "expected WAL mount, no downwardAPI mount in %q container", pod.Spec.InitContainers[0].Name) }) t.Run("WithCustomSidecarContainer", func(t *testing.T) { @@ -529,7 +532,7 @@ volumes: InstancePod(ctx, cluster, sidecarInstance, serverSecretProjection, clientSecretProjection, dataVolume, nil, nil, pod) - assert.Equal(t, len(pod.Containers), 2, "expected 2 containers in Pod, got %d", len(pod.Containers)) + assert.Equal(t, len(pod.Spec.Containers), 2, "expected 2 containers in Pod") }) t.Run("SidecarEnabled", func(t *testing.T) { @@ -542,11 +545,11 @@ volumes: InstancePod(ctx, cluster, sidecarInstance, serverSecretProjection, clientSecretProjection, dataVolume, nil, nil, pod) - assert.Equal(t, len(pod.Containers), 3, "expected 3 containers in Pod, got %d", len(pod.Containers)) + assert.Equal(t, len(pod.Spec.Containers), 3, "expected 3 containers in Pod") var found bool - for i := range pod.Containers { - if pod.Containers[i].Name == "customsidecar1" { + for i := range pod.Spec.Containers { + if pod.Spec.Containers[i].Name == "customsidecar1" { found = true break } @@ -579,7 +582,7 @@ volumes: InstancePod(ctx, cluster, instance, serverSecretProjection, clientSecretProjection, dataVolume, nil, tablespaceVolumes, pod) - assert.Assert(t, cmp.MarshalMatches(pod.Containers[0].VolumeMounts, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.Containers[0].VolumeMounts, ` - mountPath: /pgconf/tls name: cert-volume readOnly: true @@ -591,10 +594,10 @@ volumes: - mountPath: /tablespaces/castle name: tablespace-castle - mountPath: /tablespaces/trial - name: tablespace-trial`), "expected tablespace mount(s) in %q container", pod.Containers[0].Name) + name: tablespace-trial`), "expected tablespace mount(s) in %q container", pod.Spec.Containers[0].Name) // InitContainer has all mountPaths, except downwardAPI and additionalConfig - assert.Assert(t, cmp.MarshalMatches(pod.InitContainers[0].VolumeMounts, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.InitContainers[0].VolumeMounts, ` - mountPath: /pgconf/tls name: cert-volume readOnly: true @@ -603,7 +606,7 @@ volumes: - mountPath: /tablespaces/castle name: tablespace-castle - mountPath: /tablespaces/trial - name: tablespace-trial`), "expected tablespace mount(s) in %q container", pod.InitContainers[0].Name) + name: tablespace-trial`), "expected tablespace mount(s) in %q container", pod.Spec.InitContainers[0].Name) }) t.Run("WithWALVolumeWithWALVolumeSpec", func(t *testing.T) { @@ -611,16 +614,16 @@ volumes: walVolume.Name = "walvol" instance := new(v1beta1.PostgresInstanceSetSpec) - instance.WALVolumeClaimSpec = new(corev1.PersistentVolumeClaimSpec) + instance.WALVolumeClaimSpec = new(v1beta1.VolumeClaimSpec) - pod := new(corev1.PodSpec) + pod := new(corev1.PodTemplateSpec) InstancePod(ctx, cluster, instance, serverSecretProjection, clientSecretProjection, dataVolume, walVolume, nil, pod) - assert.Assert(t, len(pod.Containers) > 0) - assert.Assert(t, len(pod.InitContainers) > 0) + assert.Assert(t, len(pod.Spec.Containers) > 0) + assert.Assert(t, len(pod.Spec.InitContainers) > 0) - assert.Assert(t, cmp.MarshalMatches(pod.Containers[0].VolumeMounts, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.Containers[0].VolumeMounts, ` - mountPath: /pgconf/tls name: cert-volume readOnly: true @@ -630,18 +633,18 @@ volumes: name: database-containerinfo readOnly: true - mountPath: /pgwal - name: postgres-wal`), "expected WAL and downwardAPI mounts in %q container", pod.Containers[0].Name) + name: postgres-wal`), "expected WAL and downwardAPI mounts in %q container", pod.Spec.Containers[0].Name) - assert.Assert(t, cmp.MarshalMatches(pod.InitContainers[0].VolumeMounts, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.InitContainers[0].VolumeMounts, ` - mountPath: /pgconf/tls name: cert-volume readOnly: true - mountPath: /pgdata name: postgres-data - mountPath: /pgwal - name: postgres-wal`), "expected WAL mount, no downwardAPI mount in %q container", pod.InitContainers[0].Name) + name: postgres-wal`), "expected WAL mount, no downwardAPI mount in %q container", pod.Spec.InitContainers[0].Name) - assert.Assert(t, cmp.MarshalMatches(pod.Volumes, ` + assert.Assert(t, cmp.MarshalMatches(pod.Spec.Volumes, ` - name: cert-volume projected: defaultMode: 384 @@ -670,22 +673,22 @@ volumes: - path: cpu_limit resourceFieldRef: containerName: database - divisor: 1m + divisor: "0" resource: limits.cpu - path: cpu_request resourceFieldRef: containerName: database - divisor: 1m + divisor: "0" resource: requests.cpu - path: mem_limit resourceFieldRef: containerName: database - divisor: 1Mi + divisor: "0" resource: limits.memory - path: mem_request resourceFieldRef: containerName: database - divisor: 1Mi + divisor: "0" resource: requests.memory - fieldRef: apiVersion: v1 @@ -702,8 +705,70 @@ volumes: `), "expected WAL volume") // Startup moves WAL files to WAL volume. - assert.DeepEqual(t, pod.InitContainers[0].Command[4:], - []string{"startup", "11", "/pgwal/pg11_wal", "/pgdata/pgbackrest/log"}) + assert.DeepEqual(t, pod.Spec.InitContainers[0].Command[4:], + []string{"startup", "11", "/pgwal/pg11_wal"}) + }) + + t.Run("TempVolume", func(t *testing.T) { + instance := new(v1beta1.PostgresInstanceSetSpec) + require.UnmarshalInto(t, &instance, `{ + volumes: { temp: { + resources: { requests: { storage: 99Mi } }, + storageClassName: somesuch, + } }, + }`) + + pod := new(corev1.PodTemplateSpec) + InstancePod(ctx, cluster, instance, + serverSecretProjection, clientSecretProjection, dataVolume, nil, nil, pod) + + assert.Assert(t, len(pod.Spec.Containers) > 0) + assert.Assert(t, cmp.MarshalContains(pod.Spec.Containers[0].VolumeMounts, ` +- mountPath: /pgtmp + name: postgres-temp +`), "expected temp mount in %q container", pod.Spec.Containers[0].Name) + + // NOTE: `creationTimestamp: null` appears in the resulting pod, + // but it does not affect the PVC or reconciliation events; + // possibly https://pr.k8s.io/100032 + assert.Assert(t, cmp.MarshalContains(pod.Spec.Volumes, ` +- ephemeral: + volumeClaimTemplate: + metadata: + creationTimestamp: null + spec: + resources: + requests: + storage: 99Mi + storageClassName: somesuch + name: postgres-temp +`), "expected definition in the pod") + + t.Run("Metadata", func(t *testing.T) { + annotated := pod.DeepCopy() + annotated.Annotations = map[string]string{"n1": "etc"} + annotated.Labels = map[string]string{"gg": "asdf"} + + InstancePod(ctx, cluster, instance, + serverSecretProjection, clientSecretProjection, dataVolume, nil, nil, annotated) + + assert.Assert(t, cmp.MarshalContains(annotated.Spec.Volumes, ` +- ephemeral: + volumeClaimTemplate: + metadata: + annotations: + n1: etc + creationTimestamp: null + labels: + gg: asdf + spec: + resources: + requests: + storage: 99Mi + storageClassName: somesuch + name: postgres-temp +`), "expected definition in the pod") + }) }) } diff --git a/internal/postgres/sql.go b/internal/postgres/sql.go new file mode 100644 index 0000000000..9eeb1586a0 --- /dev/null +++ b/internal/postgres/sql.go @@ -0,0 +1,22 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import "strings" + +// escapeLiteral is called by QuoteLiteral to add backslashes before special +// characters of the "escape" string syntax. Double quote marks to escape them +// regardless of the "backslash_quote" parameter. +var escapeLiteral = strings.NewReplacer(`'`, `''`, `\`, `\\`).Replace + +// QuoteLiteral escapes v so it can be safely used as a literal (or constant) +// in an SQL statement. +func QuoteLiteral(v string) string { + // Use the "escape" syntax to ensure that backslashes behave consistently regardless + // of the "standard_conforming_strings" parameter. Include a space before so + // the "E" cannot change the meaning of an adjacent SQL keyword or identifier. + // - https://www.postgresql.org/docs/current/sql-syntax-lexical.html + return ` E'` + escapeLiteral(v) + `'` +} diff --git a/internal/postgres/sql_test.go b/internal/postgres/sql_test.go new file mode 100644 index 0000000000..d5f998fa30 --- /dev/null +++ b/internal/postgres/sql_test.go @@ -0,0 +1,16 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestQuoteLiteral(t *testing.T) { + assert.Equal(t, QuoteLiteral(``), ` E''`) + assert.Equal(t, QuoteLiteral(`ab"cd\ef'gh`), ` E'ab"cd\\ef''gh'`) +} diff --git a/internal/postgres/users.go b/internal/postgres/users.go index be8785a4e5..163f494414 100644 --- a/internal/postgres/users.go +++ b/internal/postgres/users.go @@ -1,7 +1,10 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 +// [pg_query.Parse] requires CGO to compile and call https://github.com/pganalyze/libpg_query +//go:build cgo + package postgres import ( @@ -10,7 +13,7 @@ import ( "encoding/json" "strings" - pg_query "github.com/pganalyze/pg_query_go/v5" + pg_query "github.com/pganalyze/pg_query_go/v6" "github.com/crunchydata/postgres-operator/internal/feature" "github.com/crunchydata/postgres-operator/internal/logging" @@ -66,6 +69,10 @@ func WriteUsersInPostgreSQL( var err error var sql bytes.Buffer + // Do not wait for changes to be replicated. [Since PostgreSQL v9.1] + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + _, _ = sql.WriteString(`SET synchronous_commit = LOCAL;`) + // Prevent unexpected dereferences by emptying "search_path". The "pg_catalog" // schema is still searched, and only temporary objects can be created. // - https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-SEARCH-PATH @@ -99,7 +106,7 @@ CREATE TEMPORARY TABLE input (id serial, data json); "databases": databases, "options": options, "username": spec.Name, - "verifier": verifiers[string(spec.Name)], + "verifier": verifiers[spec.Name], }) } } @@ -187,9 +194,9 @@ func WriteUsersSchemasInPostgreSQL(ctx context.Context, exec Executor, spec := users[i] // We skip if the user has the name of a reserved schema - if RESERVED_SCHEMA_NAMES[string(spec.Name)] { + if RESERVED_SCHEMA_NAMES[spec.Name] { log.V(1).Info("Skipping schema creation for user with reserved name", - "name", string(spec.Name)) + "name", spec.Name) continue } @@ -216,6 +223,10 @@ func WriteUsersSchemasInPostgreSQL(ctx context.Context, exec Executor, // - https://www.postgresql.org/docs/current/runtime-config-client.html `SET client_min_messages = WARNING;`, + // Do not wait for changes to be replicated. [Since PostgreSQL v9.1] + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + `SET synchronous_commit = LOCAL;`, + // Creates a schema named after and owned by the user // - https://www.postgresql.org/docs/current/ddl-schemas.html // - https://www.postgresql.org/docs/current/sql-createschema.html @@ -228,7 +239,7 @@ func WriteUsersSchemasInPostgreSQL(ctx context.Context, exec Executor, }, "\n"), map[string]string{ "databases": string(databases), - "username": string(spec.Name), + "username": spec.Name, "ON_ERROR_STOP": "on", // Abort when any one statement fails. "QUIET": "on", // Do not print successful commands to stdout. diff --git a/internal/postgres/users_test.go b/internal/postgres/users_test.go index 141175c78e..313a9f0134 100644 --- a/internal/postgres/users_test.go +++ b/internal/postgres/users_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -63,7 +63,7 @@ func TestWriteUsersInPostgreSQL(t *testing.T) { b, err := io.ReadAll(stdin) assert.NilError(t, err) assert.Equal(t, string(b), strings.TrimSpace(` -SET search_path TO ''; +SET synchronous_commit = LOCAL;SET search_path TO ''; CREATE TEMPORARY TABLE input (id serial, data json); \copy input (data) from stdin with (format text) \. @@ -131,7 +131,7 @@ COMMIT;`)) []v1beta1.PostgresUserSpec{ { Name: "user-no-options", - Databases: []v1beta1.PostgresIdentifier{"db1"}, + Databases: []string{"db1"}, }, { Name: "user-no-databases", @@ -175,7 +175,7 @@ COMMIT;`)) []v1beta1.PostgresUserSpec{ { Name: "postgres", - Databases: []v1beta1.PostgresIdentifier{"all", "ignored"}, + Databases: []string{"all", "ignored"}, Options: "NOLOGIN CONNECTION LIMIT 0", }, }, @@ -213,18 +213,18 @@ func TestWriteUsersSchemasInPostgreSQL(t *testing.T) { []v1beta1.PostgresUserSpec{ { Name: "user-single-db", - Databases: []v1beta1.PostgresIdentifier{"db1"}, + Databases: []string{"db1"}, }, { Name: "user-no-databases", }, { Name: "user-multi-dbs", - Databases: []v1beta1.PostgresIdentifier{"db1", "db2"}, + Databases: []string{"db1", "db2"}, }, { Name: "public", - Databases: []v1beta1.PostgresIdentifier{"db3"}, + Databases: []string{"db3"}, }, }, )) diff --git a/internal/postgres/versions.go b/internal/postgres/versions.go new file mode 100644 index 0000000000..17d067966d --- /dev/null +++ b/internal/postgres/versions.go @@ -0,0 +1,26 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import "time" + +// https://www.postgresql.org/support/versioning +var finalReleaseDates = map[int]time.Time{ + 10: time.Date(2022, time.November+1, 10, 0, 0, 0, 0, time.UTC), + 11: time.Date(2023, time.November+1, +9, 0, 0, 0, 0, time.UTC), + 12: time.Date(2024, time.November+1, 14, 0, 0, 0, 0, time.UTC), + 13: time.Date(2025, time.November+1, 13, 0, 0, 0, 0, time.UTC), + 14: time.Date(2026, time.November+1, 12, 0, 0, 0, 0, time.UTC), + 15: time.Date(2027, time.November+1, 11, 0, 0, 0, 0, time.UTC), + 16: time.Date(2028, time.November+1, +9, 0, 0, 0, 0, time.UTC), + 17: time.Date(2029, time.November+1, +8, 0, 0, 0, 0, time.UTC), +} + +// ReleaseIsFinal returns whether or not t is definitively past the final +// scheduled release of a Postgres version. +func ReleaseIsFinal(majorVersion int, t time.Time) bool { + known, ok := finalReleaseDates[majorVersion] + return ok && t.After(known) +} diff --git a/internal/postgres/versions_test.go b/internal/postgres/versions_test.go new file mode 100644 index 0000000000..089deef399 --- /dev/null +++ b/internal/postgres/versions_test.go @@ -0,0 +1,34 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package postgres + +import ( + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestReleaseIsFinal(t *testing.T) { + // On November 4th, 2024, PG 10 and 11 were EOL and 12-17 were supported. + testDate, err := time.Parse("2006-Jan-02", "2024-Nov-04") + assert.NilError(t, err) + assert.Check(t, ReleaseIsFinal(10, testDate)) + assert.Check(t, ReleaseIsFinal(11, testDate)) + assert.Check(t, !ReleaseIsFinal(12, testDate)) + assert.Check(t, !ReleaseIsFinal(13, testDate)) + assert.Check(t, !ReleaseIsFinal(14, testDate)) + assert.Check(t, !ReleaseIsFinal(15, testDate)) + assert.Check(t, !ReleaseIsFinal(16, testDate)) + assert.Check(t, !ReleaseIsFinal(17, testDate)) + + // On December 15th, 2024 we alert that PG 12 is EOL + testDate = testDate.AddDate(0, 1, 11) + assert.Check(t, ReleaseIsFinal(12, testDate)) + + // ReleaseIsFinal covers PG versions 10 and greater. Any version not covered + // by the case statement in ReleaseIsFinal returns false + assert.Check(t, !ReleaseIsFinal(1, testDate)) +} diff --git a/internal/postgres/wal.md b/internal/postgres/wal.md index afb094c20e..b88185aa21 100644 --- a/internal/postgres/wal.md +++ b/internal/postgres/wal.md @@ -1,5 +1,5 @@ diff --git a/internal/registration/interface.go b/internal/registration/interface.go index 578a064e2b..c0d4e390ad 100644 --- a/internal/registration/interface.go +++ b/internal/registration/interface.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/registration/runner.go b/internal/registration/runner.go index 0d607e1e94..b50ceeb4ed 100644 --- a/internal/registration/runner.go +++ b/internal/registration/runner.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -181,10 +181,6 @@ func (r *Runner) Start(ctx context.Context) error { r.changed() } case <-ctx.Done(): - // https://github.com/kubernetes-sigs/controller-runtime/issues/1927 - if errors.Is(ctx.Err(), context.Canceled) { - return nil - } return ctx.Err() } } diff --git a/internal/registration/runner_test.go b/internal/registration/runner_test.go index 8e75848986..32bea6a485 100644 --- a/internal/registration/runner_test.go +++ b/internal/registration/runner_test.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -20,9 +20,9 @@ import ( "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/controller-runtime/pkg/manager" + "github.com/crunchydata/postgres-operator/internal/controller/runtime" "github.com/crunchydata/postgres-operator/internal/testing/events" ) @@ -381,7 +381,7 @@ func TestRunnerRequiredEvents(t *testing.T) { conditions := append([]metav1.Condition{}, tt.before...) object := &corev1.ConfigMap{} - recorder := events.NewRecorder(t, scheme.Scheme) + recorder := events.NewRecorder(t, runtime.Scheme) result := r.Required(recorder, object, &conditions) @@ -413,7 +413,7 @@ func TestRunnerRequiredEvents(t *testing.T) { } { conditions := append([]metav1.Condition{}, tt.before...) object := &corev1.ConfigMap{} - recorder := events.NewRecorder(t, scheme.Scheme) + recorder := events.NewRecorder(t, runtime.Scheme) result := r.Required(recorder, object, &conditions) @@ -441,7 +441,7 @@ func TestRunnerRequiredEvents(t *testing.T) { } { conditions := append([]metav1.Condition{}, tt.before...) object := &corev1.ConfigMap{} - recorder := events.NewRecorder(t, scheme.Scheme) + recorder := events.NewRecorder(t, runtime.Scheme) result := r.Required(recorder, object, &conditions) @@ -475,7 +475,7 @@ func TestRunnerRequiredEvents(t *testing.T) { conditions := append([]metav1.Condition{}, tt.before...) object := &corev1.ConfigMap{} - recorder := events.NewRecorder(t, scheme.Scheme) + recorder := events.NewRecorder(t, runtime.Scheme) result := r.Required(recorder, object, &conditions) @@ -508,7 +508,7 @@ func TestRunnerRequiredEvents(t *testing.T) { } { conditions := append([]metav1.Condition{}, tt.before...) object := &corev1.ConfigMap{} - recorder := events.NewRecorder(t, scheme.Scheme) + recorder := events.NewRecorder(t, runtime.Scheme) result := r.Required(recorder, object, &conditions) diff --git a/internal/registration/testing.go b/internal/registration/testing.go index 1418f6d2d3..7ea0032b31 100644 --- a/internal/registration/testing.go +++ b/internal/registration/testing.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/shell/paths.go b/internal/shell/paths.go new file mode 100644 index 0000000000..701144694a --- /dev/null +++ b/internal/shell/paths.go @@ -0,0 +1,83 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +// We want the [filepath] package to behave correctly for Linux containers. +//go:build unix + +package shell + +import ( + "fmt" + "io/fs" + "path/filepath" + "slices" + "strings" +) + +// CleanFileName returns the suffix of path after its last slash U+002F. +// This is similar to "basename" except this returns empty string when: +// - The final character of path is slash U+002F, or +// - The result would be "." or ".." +// +// See: +// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/basename.html +func CleanFileName(path string) string { + if i := strings.LastIndexByte(path, '/'); i >= 0 { + path = path[i+1:] + } + if path != "." && path != ".." { + return path + } + return "" +} + +// MakeDirectories returns a list of POSIX shell commands that ensure each path +// exists. It creates every directory leading to path from (but not including) +// base and sets their permissions for Kubernetes, regardless of umask. +// +// See: +// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/chmod.html +// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/mkdir.html +// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/test.html +// - https://pubs.opengroup.org/onlinepubs/9799919799/utilities/umask.html +func MakeDirectories(base string, paths ...string) string { + // Without any paths, return a command that succeeds when the base path exists. + if len(paths) == 0 { + return `test -d ` + QuoteWord(base) + } + + allPaths := slices.Clone(paths) + for _, p := range paths { + if r, err := filepath.Rel(base, p); err == nil && filepath.IsLocal(r) { + // The result of [filepath.Rel] is a shorter representation of the full path; skip it. + r = filepath.Dir(r) + + for r != "." { + allPaths = append(allPaths, filepath.Join(base, r)) + r = filepath.Dir(r) + } + } + } + + // Pod "securityContext.fsGroup" ensures processes and filesystems agree on a GID. + // Use the same permissions for group and owner. + const perms fs.FileMode = 0 | + // S_IRWXU: enable owner read, write, and execute permissions. + 0o0700 | + // S_IRWXG: enable group read, write, and execute permissions. + 0o0070 | + // S_IXOTH, S_IROTH: enable other read and execute permissions. + 0o0001 | 0o0004 + + return `` + + // Create all the paths and any missing parents. + `mkdir -p ` + strings.Join(QuoteWords(paths...), " ") + + + // Try to set the permissions of every path and each parent. + // This swallows the exit status of `chmod` because not all filesystems + // tolerate the operation; CIFS and NFS are notable examples. + fmt.Sprintf(` && { chmod %#o %s || :; }`, + perms, strings.Join(QuoteWords(allPaths...), " "), + ) +} diff --git a/internal/shell/paths_test.go b/internal/shell/paths_test.go new file mode 100644 index 0000000000..b5adb69b17 --- /dev/null +++ b/internal/shell/paths_test.go @@ -0,0 +1,103 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package shell + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gotest.tools/v3/assert" + "sigs.k8s.io/yaml" + + "github.com/crunchydata/postgres-operator/internal/testing/require" +) + +func TestCleanFileName(t *testing.T) { + t.Parallel() + + t.Run("Empty", func(t *testing.T) { + assert.Equal(t, CleanFileName(""), "") + }) + + t.Run("Dots", func(t *testing.T) { + assert.Equal(t, CleanFileName("."), "") + assert.Equal(t, CleanFileName(".."), "") + assert.Equal(t, CleanFileName("..."), "...") + assert.Equal(t, CleanFileName("././/.././../."), "") + assert.Equal(t, CleanFileName("././/.././../.."), "") + assert.Equal(t, CleanFileName("././/.././../../x.j"), "x.j") + }) + + t.Run("Directories", func(t *testing.T) { + assert.Equal(t, CleanFileName("/"), "") + assert.Equal(t, CleanFileName("//"), "") + assert.Equal(t, CleanFileName("asdf/"), "") + assert.Equal(t, CleanFileName("asdf//12.3"), "12.3") + assert.Equal(t, CleanFileName("//////"), "") + assert.Equal(t, CleanFileName("//////gg"), "gg") + }) + + t.Run("NoSeparators", func(t *testing.T) { + assert.Equal(t, CleanFileName("asdf12.3.ssgg"), "asdf12.3.ssgg") + }) +} + +func TestMakeDirectories(t *testing.T) { + t.Parallel() + + t.Run("NoPaths", func(t *testing.T) { + assert.Equal(t, + MakeDirectories("/asdf/jklm"), + `test -d '/asdf/jklm'`) + }) + + t.Run("Children", func(t *testing.T) { + assert.DeepEqual(t, + MakeDirectories("/asdf", "/asdf/jklm", "/asdf/qwerty"), + `mkdir -p '/asdf/jklm' '/asdf/qwerty' && { chmod 0775 '/asdf/jklm' '/asdf/qwerty' || :; }`) + }) + + t.Run("Grandchild", func(t *testing.T) { + script := MakeDirectories("/asdf", "/asdf/qwerty/boots") + assert.DeepEqual(t, script, + `mkdir -p '/asdf/qwerty/boots' && { chmod 0775 '/asdf/qwerty/boots' '/asdf/qwerty' || :; }`) + + t.Run("ShellCheckPOSIX", func(t *testing.T) { + shellcheck := require.ShellCheck(t) + + dir := t.TempDir() + file := filepath.Join(dir, "script.sh") + assert.NilError(t, os.WriteFile(file, []byte(script), 0o600)) + + // Expect ShellCheck for "sh" to be happy. + // - https://www.shellcheck.net/wiki/SC2148 + cmd := exec.CommandContext(t.Context(), shellcheck, "--enable=all", "--shell=sh", file) + output, err := cmd.CombinedOutput() + assert.NilError(t, err, "%q\n%s", cmd.Args, output) + }) + }) + + t.Run("Long", func(t *testing.T) { + script := MakeDirectories("/", strings.Repeat("/asdf", 20)) + + t.Run("PrettyYAML", func(t *testing.T) { + b, err := yaml.Marshal(script) + s := string(b) + assert.NilError(t, err) + assert.Assert(t, !strings.HasPrefix(s, `"`) && !strings.HasPrefix(s, `'`), + "expected plain unquoted scalar, got:\n%s", b) + }) + }) + + t.Run("Unrelated", func(t *testing.T) { + assert.Equal(t, + MakeDirectories("/one", "/two/three/four"), + `mkdir -p '/two/three/four' && { chmod 0775 '/two/three/four' || :; }`, + "expected no chmod of parent directories") + }) +} diff --git a/internal/shell/quote.go b/internal/shell/quote.go new file mode 100644 index 0000000000..bac8d14f93 --- /dev/null +++ b/internal/shell/quote.go @@ -0,0 +1,34 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package shell + +import "strings" + +// escapeSingleQuoted is used by [QuoteWord]. +var escapeSingleQuoted = strings.NewReplacer( + // slightly shorter results for the unlikely pair of quotes. + `''`, `'"''"'`, + + // first, close the single-quote U+0027, + // add one between double-quotes U+0022, + // then reopen the single-quote U+0027. + `'`, `'"'"'`, +).Replace + +// QuoteWord ensures that v is interpreted by a shell as a single word. +func QuoteWord(v string) string { + // https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html + // https://www.gnu.org/software/bash/manual/html_node/Quoting.html + return `'` + escapeSingleQuoted(v) + `'` +} + +// QuoteWords ensures that s is interpreted by a shell as individual words. +func QuoteWords(s ...string) []string { + quoted := make([]string, len(s)) + for i := range s { + quoted[i] = QuoteWord(s[i]) + } + return quoted +} diff --git a/internal/shell/quote_test.go b/internal/shell/quote_test.go new file mode 100644 index 0000000000..eaea72f673 --- /dev/null +++ b/internal/shell/quote_test.go @@ -0,0 +1,34 @@ +// Copyright 2024 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package shell + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestQuoteWord(t *testing.T) { + assert.Equal(t, QuoteWord(""), `''`, + "expected empty and single-quoted") + + assert.Equal(t, QuoteWord("abc"), `'abc'`, + "expected single-quoted") + + assert.Equal(t, QuoteWord(`a" b"c`), `'a" b"c'`, + "expected easy double-quotes") + + assert.Equal(t, QuoteWord(`a' b'c`), + `'a'`+`"'"`+`' b'`+`"'"`+`'c'`, + "expected close-quote-open twice") + + assert.Equal(t, QuoteWord(`a''b`), + `'a'`+`"''"`+`'b'`, + "expected close-quotes-open once") + + assert.Equal(t, QuoteWord(`x''''y`), + `'x'`+`"''"`+`''`+`"''"`+`'y'`, + "expected close-quotes-open twice") +} diff --git a/internal/testing/cmp/cmp.go b/internal/testing/cmp/cmp.go index 265a598064..d7b5764e41 100644 --- a/internal/testing/cmp/cmp.go +++ b/internal/testing/cmp/cmp.go @@ -1,10 +1,11 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package cmp import ( + "regexp" "strings" gocmp "github.com/google/go-cmp/cmp" @@ -46,10 +47,33 @@ func Contains(collection, item any) Comparison { // succeeds if the values are equal. The comparison can be customized using // comparison Options. See [github.com/google/go-cmp/cmp.Option] constructors // and [github.com/google/go-cmp/cmp/cmpopts]. -func DeepEqual(x, y any, opts ...gocmp.Option) Comparison { +func DeepEqual[T any](x, y T, opts ...gocmp.Option) Comparison { return gotest.DeepEqual(x, y, opts...) } +// Len succeeds if actual has the expected length. +func Len[Slice ~[]E, E any](actual Slice, expected int) Comparison { + return gotest.Len(actual, expected) +} + +// LenMap succeeds if actual has the expected length. +func LenMap[Map ~map[K]V, K comparable, V any](actual Map, expected int) Comparison { + // There doesn't seem to be a way to express "map or slice" in type constraints + // that [Go 1.22] compiler can nicely infer. Ideally, this function goes + // away when a better constraint can be expressed on [Len]. + + return gotest.Len(actual, expected) +} + +// MarshalContains converts actual to YAML and succeeds if expected is in the result. +func MarshalContains(actual any, expected string) Comparison { + b, err := yaml.Marshal(actual) + if err != nil { + return func() gotest.Result { return gotest.ResultFromError(err) } + } + return Contains(string(b), expected) +} + // MarshalMatches converts actual to YAML and compares that to expected. func MarshalMatches(actual any, expected string) Comparison { b, err := yaml.Marshal(actual) @@ -59,9 +83,9 @@ func MarshalMatches(actual any, expected string) Comparison { return gotest.DeepEqual(string(b), strings.Trim(expected, "\t\n")+"\n") } -// Regexp succeeds if value contains any match of the regular expression re. +// Regexp succeeds if value contains any match of the regular expression. // The regular expression may be a *regexp.Regexp or a string that is a valid // regexp pattern. -func Regexp(re any, value string) Comparison { - return gotest.Regexp(re, value) +func Regexp[RE *regexp.Regexp | ~string](regex RE, value string) Comparison { + return gotest.Regexp(regex, value) } diff --git a/internal/testing/events/recorder.go b/internal/testing/events/recorder.go index 23c03a4c40..e76ef21eb3 100644 --- a/internal/testing/events/recorder.go +++ b/internal/testing/events/recorder.go @@ -1,4 +1,4 @@ -// Copyright 2022 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2022 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/testing/require/encoding.go b/internal/testing/require/encoding.go new file mode 100644 index 0000000000..8016c1921a --- /dev/null +++ b/internal/testing/require/encoding.go @@ -0,0 +1,61 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package require + +import ( + "errors" + "testing" + + "gotest.tools/v3/assert" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/json" + "sigs.k8s.io/yaml" +) + +// UnmarshalInto parses input as YAML (or JSON) the same way as the Kubernetes +// API Server writing into output. It calls t.Fatal when something fails. +func UnmarshalInto[Data ~string | ~[]byte, Destination *T, T any]( + t testing.TB, output Destination, input Data, +) { + t.Helper() + + // The REST API uses serializers: + // + // https://pkg.go.dev/k8s.io/apimachinery/pkg/runtime/serializer/json + // https://pkg.go.dev/k8s.io/apimachinery/pkg/runtime/serializer/yaml + // + // The util package follows similar paths (strict, preserve ints, etc.) + // + // https://pkg.go.dev/k8s.io/apimachinery/pkg/util/json + // https://pkg.go.dev/k8s.io/apimachinery/pkg/util/yaml + + data, err := yaml.YAMLToJSONStrict([]byte(input)) + assert.NilError(t, err) + + strict, err := json.UnmarshalStrict(data, output) + assert.NilError(t, err) + assert.NilError(t, errors.Join(strict...)) +} + +// UnmarshalIntoField parses input as YAML (or JSON) the same way as the Kubernetes API Server. +// The result goes into a (nested) field of output. It calls t.Fatal when something fails. +func UnmarshalIntoField[Data ~string | ~[]byte]( + t testing.TB, output *unstructured.Unstructured, input Data, fields ...string, +) { + t.Helper() + + if len(fields) == 0 { + t.Fatal("BUG: called without a destination") + } + + if output.Object == nil { + output.Object = map[string]any{} + } + + var value any + UnmarshalInto(t, &value, []byte(input)) + + assert.NilError(t, unstructured.SetNestedField(output.Object, value, fields...)) +} diff --git a/internal/testing/require/encoding_test.go b/internal/testing/require/encoding_test.go new file mode 100644 index 0000000000..cbdf93963c --- /dev/null +++ b/internal/testing/require/encoding_test.go @@ -0,0 +1,83 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package require_test + +import ( + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crunchydata/postgres-operator/internal/testing/require" +) + +func TestUnmarshalInto(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + input string + expected any + }{ + // Any fraction that amounts to an integral number is converted to an integer. + // See: https://go.dev/play/p/dvXRVhYO8UH + {input: `3`, expected: int64(3)}, + {input: `3.000`, expected: int64(3)}, + {input: `0.03e2`, expected: int64(3)}, + {input: `{a: 5}`, expected: map[string]any{"a": int64(5)}}, + {input: `{a: 5.000}`, expected: map[string]any{"a": int64(5)}}, + {input: `{a: 0.05e2}`, expected: map[string]any{"a": int64(5)}}, + + // YAML or JSON + {input: `asdf`, expected: "asdf"}, + {input: `"asdf"`, expected: "asdf"}, + {input: `[1, 2.3, true]`, expected: []any{int64(1), float64(2.3), true}}, + {input: `{a: b, c, d}`, expected: map[string]any{"a": "b", "c": nil, "d": nil}}, + } { + sink := reflect.Zero(reflect.TypeOf(tt.expected)).Interface() + require.UnmarshalInto(t, &sink, tt.input) + + if !reflect.DeepEqual(tt.expected, sink) { + t.Fatalf("expected %[1]T(%#[1]v), got %[2]T(%#[2]v)", tt.expected, sink) + } + } +} + +func TestUnmarshalIntoField(t *testing.T) { + t.Parallel() + + var u unstructured.Unstructured + + t.Run("NestedString", func(t *testing.T) { + u.Object = nil + require.UnmarshalIntoField(t, &u, `asdf`, "spec", "nested", "field") + + if !reflect.DeepEqual(u.Object, map[string]any{ + "spec": map[string]any{ + "nested": map[string]any{ + "field": "asdf", + }, + }, + }) { + t.Fatalf("got %[1]T(%#[1]v)", u.Object) + } + }) + + t.Run("Numeric", func(t *testing.T) { + u.Object = nil + require.UnmarshalIntoField(t, &u, `99`, "one") + require.UnmarshalIntoField(t, &u, `5.7`, "two") + + // Kubernetes distinguishes between integral and fractional numbers. + if !reflect.DeepEqual(u.Object, map[string]any{ + "one": int64(99), + "two": float64(5.7), + }) { + t.Fatalf("got %[1]T(%#[1]v)", u.Object) + } + }) + + // Correctly fails with: BUG: called without a destination + // require.UnmarshalIntoField(t, &u, `true`) +} diff --git a/internal/testing/require/errors.go b/internal/testing/require/errors.go new file mode 100644 index 0000000000..128a0397b0 --- /dev/null +++ b/internal/testing/require/errors.go @@ -0,0 +1,33 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package require + +import ( + "errors" + "testing" + + "gotest.tools/v3/assert" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// StatusError returns the [metav1.Status] within err's tree. +// It calls t.Fatal when err is nil or there is no status. +func StatusError(t testing.TB, err error) metav1.Status { + status, ok := err.(apierrors.APIStatus) + + assert.Assert(t, ok || errors.As(err, &status), + "%T does not implement %T", err, status) + + return status.Status() +} + +// Value returns v or panics when err is not nil. +func Value[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} diff --git a/internal/testing/require/exec.go b/internal/testing/require/exec.go index c182e84996..a9e028c55e 100644 --- a/internal/testing/require/exec.go +++ b/internal/testing/require/exec.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -38,7 +38,7 @@ func executable(name string, args ...string) func(testing.TB) string { t.Helper() once.Do(func() { path, err := exec.LookPath(name) - cmd := exec.Command(path, args...) // #nosec G204 -- args from init() + cmd := exec.CommandContext(t.Context(), path, args...) // #nosec G204 -- args from init() if err != nil { result = func(t testing.TB) string { diff --git a/internal/testing/require/kubernetes.go b/internal/testing/require/kubernetes.go index df21bca058..3953d7c384 100644 --- a/internal/testing/require/kubernetes.go +++ b/internal/testing/require/kubernetes.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -11,10 +11,12 @@ import ( goruntime "runtime" "strings" "sync" - "testing" + "golang.org/x/tools/go/packages" "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/version" + "k8s.io/client-go/discovery" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" @@ -22,6 +24,14 @@ import ( "github.com/crunchydata/postgres-operator/internal/controller/runtime" ) +type TestingT interface { + assert.TestingT + Cleanup(func()) + Helper() + Name() string + SkipNow() +} + // https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/envtest#pkg-constants var envtestVarsSet = os.Getenv("KUBEBUILDER_ASSETS") != "" || strings.EqualFold(os.Getenv("USE_EXISTING_CLUSTER"), "true") @@ -29,7 +39,7 @@ var envtestVarsSet = os.Getenv("KUBEBUILDER_ASSETS") != "" || // EnvTest returns an unstarted Environment with crds. It calls t.Skip when // the "KUBEBUILDER_ASSETS" and "USE_EXISTING_CLUSTER" environment variables // are unset. -func EnvTest(t testing.TB, crds envtest.CRDInstallOptions) *envtest.Environment { +func EnvTest(t TestingT, crds envtest.CRDInstallOptions) *envtest.Environment { t.Helper() if !envtestVarsSet { @@ -48,6 +58,7 @@ var kubernetes struct { // Count references to the started Environment. count int env *envtest.Environment + err error } // Kubernetes starts or connects to a Kubernetes API and returns a client that uses it. @@ -59,64 +70,114 @@ var kubernetes struct { // // Tests that call t.Parallel might share the same local API. Call t.Parallel after this // function to ensure they share. -func Kubernetes(t testing.TB) client.Client { +func Kubernetes(t TestingT) client.Client { t.Helper() _, cc := kubernetes3(t) return cc } +// KubernetesAtLeast is the same as [Kubernetes] but also calls t.Skip when +// the connected Kubernetes API is earlier than minVersion, like "1.28" or "1.27.7". +func KubernetesAtLeast(t TestingT, minVersion string) client.Client { + t.Helper() + + expectedVersion, err := version.ParseGeneric(minVersion) + assert.NilError(t, err) + + // Start or connect to Kubernetes + env, cc := kubernetes3(t) + + dc, err := discovery.NewDiscoveryClientForConfig(env.Config) + assert.NilError(t, err) + + serverInfo, err := dc.ServerVersion() + assert.NilError(t, err) + + serverVersion, err := version.ParseGeneric(serverInfo.GitVersion) + assert.NilError(t, err) + + if serverVersion.LessThan(expectedVersion) { + t.Log("Kubernetes version", serverVersion, "is before", expectedVersion) + t.SkipNow() + } + + return cc +} + // Kubernetes2 is the same as [Kubernetes] but also returns a copy of the client // configuration. -func Kubernetes2(t testing.TB) (*rest.Config, client.Client) { +func Kubernetes2(t TestingT) (*rest.Config, client.Client) { t.Helper() env, cc := kubernetes3(t) return rest.CopyConfig(env.Config), cc } -func kubernetes3(t testing.TB) (*envtest.Environment, client.Client) { +func kubernetes3(t TestingT) (*envtest.Environment, client.Client) { t.Helper() if !envtestVarsSet { t.SkipNow() } - frames := func() *goruntime.Frames { - var pcs [5]uintptr - n := goruntime.Callers(2, pcs[:]) - return goruntime.CallersFrames(pcs[0:n]) - }() - - // Calculate the project directory as reported by [goruntime.CallersFrames]. - frame, ok := frames.Next() - self := frame.File - root := strings.TrimSuffix(self, - filepath.Join("internal", "testing", "require", "kubernetes.go")) - - // Find the first caller that is not in this file. - for ok && frame.File == self { - frame, ok = frames.Next() - } - caller := frame.File - - // Calculate the project directory path relative to the caller. - base, err := filepath.Rel(filepath.Dir(caller), root) - assert.NilError(t, err) - kubernetes.Lock() defer kubernetes.Unlock() + // Skip any remaining tests after the environment fails to start once. + // The test that tried to start the environment has reported the error. + if kubernetes.err != nil { + t.SkipNow() + } + if kubernetes.env == nil { + // Get the current call stack, minus the closure below. + frames := func() *goruntime.Frames { + var pcs [5]uintptr + n := goruntime.Callers(2, pcs[:]) + return goruntime.CallersFrames(pcs[0:n]) + }() + + // Calculate the project directory as reported by [goruntime.CallersFrames]. + frame, ok := frames.Next() + self := frame.File + root := Value(filepath.EvalSymlinks(strings.TrimSuffix(self, + filepath.Join("internal", "testing", "require", "kubernetes.go")))) + + // Find the first caller that is not in this file. + for ok && frame.File == self { + frame, ok = frames.Next() + } + caller := Value(filepath.EvalSymlinks(frame.File)) + + // Calculate the project directory path relative to the caller. + base := Value(filepath.Rel(filepath.Dir(caller), root)) + + // Calculate the snapshotter module directory path relative to the project directory. + var snapshotter string + if pkgs, err := packages.Load( + &packages.Config{Mode: packages.NeedModule}, + "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1", + ); assert.Check(t, + err == nil && len(pkgs) > 0 && pkgs[0].Module != nil, "got %v\n%#v", err, pkgs, + ) { + snapshotter = Value(filepath.Rel(root, pkgs[0].Module.Dir)) + } + env := EnvTest(t, envtest.CRDInstallOptions{ ErrorIfPathMissing: true, Paths: []string{ filepath.Join(base, "config", "crd", "bases"), - filepath.Join(base, "hack", "tools", "external-snapshotter", "client", "config", "crd"), + filepath.Join(base, snapshotter, "config", "crd"), }, Scheme: runtime.Scheme, }) - _, err := env.Start() - assert.NilError(t, err) + // There are multiple components in an environment; stop them all when any fail to start. + // Keep the error so other tests know not to try again. + _, kubernetes.err = env.Start() + if kubernetes.err != nil { + assert.Check(t, env.Stop()) + assert.NilError(t, kubernetes.err) + } kubernetes.env = env } @@ -132,6 +193,7 @@ func kubernetes3(t testing.TB) (*envtest.Environment, client.Client) { if kubernetes.count == 0 { assert.Check(t, kubernetes.env.Stop()) kubernetes.env = nil + kubernetes.err = nil } }) @@ -145,7 +207,7 @@ func kubernetes3(t testing.TB) (*envtest.Environment, client.Client) { // Namespace creates a random namespace that is deleted by t.Cleanup. It calls // t.Fatal when creation fails. The caller may delete the namespace at any time. -func Namespace(t testing.TB, cc client.Client) *corev1.Namespace { +func Namespace(t TestingT, cc client.Client) *corev1.Namespace { t.Helper() // Remove / that shows up when running a sub-test diff --git a/internal/testing/require/parallel.go b/internal/testing/require/parallel.go index 4fbdf42284..6d4f73d45f 100644 --- a/internal/testing/require/parallel.go +++ b/internal/testing/require/parallel.go @@ -1,4 +1,4 @@ -// Copyright 2022 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2022 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/testing/validation/pgadmin_test.go b/internal/testing/validation/pgadmin_test.go new file mode 100644 index 0000000000..6e50f83deb --- /dev/null +++ b/internal/testing/validation/pgadmin_test.go @@ -0,0 +1,308 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "testing" + + "gotest.tools/v3/assert" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crunchydata/postgres-operator/internal/controller/runtime" + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestPGAdminDataVolume(t *testing.T) { + ctx := context.Background() + cc := require.Kubernetes(t) + t.Parallel() + + namespace := require.Namespace(t, cc) + base := v1beta1.NewPGAdmin() + base.Namespace = namespace.Name + base.Name = "pgadmin-data-volume" + require.UnmarshalInto(t, &base.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, + }, + }`) + + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base to be valid") + + t.Run("Required", func(t *testing.T) { + u := require.Value(runtime.ToUnstructuredObject(base)) + unstructured.RemoveNestedField(u.Object, "spec", "dataVolumeClaimSpec") + + err := cc.Create(ctx, u, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "dataVolumeClaimSpec") + assert.ErrorContains(t, err, "Required") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 2)) + + assert.Equal(t, status.Details.Causes[0].Field, "spec.dataVolumeClaimSpec") + assert.Assert(t, cmp.Contains(status.Details.Causes[0].Message, "Required")) + + assert.Equal(t, string(status.Details.Causes[1].Type), "FieldValueInvalid") + assert.Assert(t, cmp.Contains(status.Details.Causes[1].Message, "rules were not checked")) + }) + + t.Run("AccessModes", func(t *testing.T) { + t.Run("Missing", func(t *testing.T) { + u := require.Value(runtime.ToUnstructuredObject(base)) + unstructured.RemoveNestedField(u.Object, "spec", "dataVolumeClaimSpec", "accessModes") + + err := cc.Create(ctx, u, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "dataVolumeClaimSpec") + assert.ErrorContains(t, err, "accessModes") + }) + + t.Run("Empty", func(t *testing.T) { + pgadmin := base.DeepCopy() + require.UnmarshalInto(t, &pgadmin.Spec.DataVolumeClaimSpec, `{ + accessModes: [], + }`) + + err := cc.Create(ctx, pgadmin, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "dataVolumeClaimSpec") + assert.ErrorContains(t, err, "accessModes") + }) + }) + + t.Run("Resources", func(t *testing.T) { + t.Run("Missing", func(t *testing.T) { + for _, tt := range [][]string{ + {"spec", "dataVolumeClaimSpec", "resources"}, + {"spec", "dataVolumeClaimSpec", "resources", "requests"}, + {"spec", "dataVolumeClaimSpec", "resources", "requests", "storage"}, + } { + u := require.Value(runtime.ToUnstructuredObject(base)) + unstructured.RemoveNestedField(u.Object, tt...) + + err := cc.Create(ctx, u, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "dataVolumeClaimSpec") + assert.ErrorContains(t, err, "storage request") + } + }) + }) +} + +func TestPGAdminInstrumentation(t *testing.T) { + ctx := context.Background() + cc := require.Kubernetes(t) + t.Parallel() + + namespace := require.Namespace(t, cc) + base := v1beta1.NewPGAdmin() + base.Namespace = namespace.Name + base.Name = "pgadmin-instrumentation" + require.UnmarshalInto(t, &base.Spec, `{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Gi } }, + }, + }`) + + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base to be valid") + + t.Run("LogsBatches", func(t *testing.T) { + t.Run("Disable", func(t *testing.T) { + for _, tt := range []struct { + batches string + valid bool + }{ + {valid: true, batches: ``}, // both null + {valid: true, batches: `minRecords: 1`}, // one null + {valid: true, batches: `maxDelay: 1s`}, // other null + + {valid: false, batches: `minRecords: 0`}, // one zero + {valid: false, batches: `maxDelay: 0m`}, // other zero + + {valid: true, batches: `minRecords: 0, maxDelay: 0m`}, // both zero + {valid: true, batches: `minRecords: 1, maxDelay: 1s`}, // both non-zero + } { + pgadmin := base.DeepCopy() + require.UnmarshalInto(t, &pgadmin.Spec.Instrumentation, `{ + logs: { batches: { `+tt.batches+` } } + }`) + + err := cc.Create(ctx, pgadmin, client.DryRunAll) + if tt.valid { + assert.NilError(t, err) + } else { + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "disable") + assert.ErrorContains(t, err, "minRecords") + assert.ErrorContains(t, err, "maxDelay") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 1)) + + for _, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, "spec.instrumentation.logs.batches") + assert.Assert(t, cmp.Contains(cause.Message, "disable batching")) + assert.Assert(t, cmp.Contains(cause.Message, "minRecords and maxDelay must be zero")) + } + } + } + }) + + t.Run("MaxDelay", func(t *testing.T) { + pgadmin := base.DeepCopy() + require.UnmarshalInto(t, &pgadmin.Spec.Instrumentation, `{ + logs: { + batches: { maxDelay: 100min }, + }, + }`) + + err := cc.Create(ctx, pgadmin, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "maxDelay") + assert.ErrorContains(t, err, "5m") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 1)) + + for _, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, "spec.instrumentation.logs.batches.maxDelay") + } + }) + + t.Run("MinMaxRecords", func(t *testing.T) { + pgadmin := base.DeepCopy() + require.UnmarshalInto(t, &pgadmin.Spec.Instrumentation, `{ + logs: { + batches: { minRecords: -11, maxRecords: 0 }, + }, + }`) + + err := cc.Create(ctx, pgadmin, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "minRecords") + assert.ErrorContains(t, err, "greater than or equal to 0") + assert.ErrorContains(t, err, "maxRecords") + assert.ErrorContains(t, err, "greater than or equal to 1") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 2)) + + for _, cause := range status.Details.Causes { + switch cause.Field { + case "spec.instrumentation.logs.batches.maxRecords": + assert.Assert(t, cmp.Contains(cause.Message, "0")) + assert.Assert(t, cmp.Contains(cause.Message, "greater than or equal to 1")) + + case "spec.instrumentation.logs.batches.minRecords": + assert.Assert(t, cmp.Contains(cause.Message, "-11")) + assert.Assert(t, cmp.Contains(cause.Message, "greater than or equal to 0")) + } + } + + t.Run("Reversed", func(t *testing.T) { + for _, batches := range []string{ + `maxRecords: 99`, // default minRecords + `minRecords: 99, maxRecords: 21`, // + } { + pgadmin := base.DeepCopy() + require.UnmarshalInto(t, &pgadmin.Spec.Instrumentation, `{ + logs: { + batches: { `+batches+` }, + }, + }`) + + err := cc.Create(ctx, pgadmin, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "minRecords") + assert.ErrorContains(t, err, "maxRecords") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 1)) + + for _, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, "spec.instrumentation.logs.batches") + assert.Assert(t, cmp.Contains(cause.Message, "minRecords cannot be larger than maxRecords")) + } + } + }) + }) + }) + + t.Run("LogsRetentionPeriod", func(t *testing.T) { + pgadmin := base.DeepCopy() + require.UnmarshalInto(t, &pgadmin.Spec, `{ + instrumentation: { + logs: { retentionPeriod: 5m }, + }, + }`) + + err := cc.Create(ctx, pgadmin, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "retentionPeriod") + assert.ErrorContains(t, err, "hour|day|week") + assert.ErrorContains(t, err, "one hour") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 2)) + + for _, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, "spec.instrumentation.logs.retentionPeriod") + } + + t.Run("Valid", func(t *testing.T) { + for _, tt := range []string{ + "28 weeks", + "90 DAY", + "1 hr", + "PT1D2H", + "1 week 2 days", + } { + u, err := runtime.ToUnstructuredObject(pgadmin) + assert.NilError(t, err) + assert.NilError(t, unstructured.SetNestedField(u.Object, + tt, "spec", "instrumentation", "logs", "retentionPeriod")) + + assert.NilError(t, cc.Create(ctx, u, client.DryRunAll), tt) + } + }) + + t.Run("Invalid", func(t *testing.T) { + for _, tt := range []string{ + // Amount too small + "0 days", + "0", + + // Text too long + "2 weeks 3 days 4 hours", + } { + u, err := runtime.ToUnstructuredObject(pgadmin) + assert.NilError(t, err) + assert.NilError(t, unstructured.SetNestedField(u.Object, + tt, "spec", "instrumentation", "logs", "retentionPeriod")) + + err = cc.Create(ctx, u, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err), tt) + assert.ErrorContains(t, err, "retentionPeriod") + } + }) + }) +} diff --git a/internal/testing/validation/postgrescluster/postgres_authentication_test.go b/internal/testing/validation/postgrescluster/postgres_authentication_test.go new file mode 100644 index 0000000000..8ae80f719a --- /dev/null +++ b/internal/testing/validation/postgrescluster/postgres_authentication_test.go @@ -0,0 +1,272 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "testing" + + "gotest.tools/v3/assert" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" + v1 "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestPostgresAuthenticationV1beta1(t *testing.T) { + ctx := t.Context() + cc := require.Kubernetes(t) + t.Parallel() + + namespace := require.Namespace(t, cc) + base := v1beta1.NewPostgresCluster() + + // required fields + require.UnmarshalInto(t, &base.Spec, `{ + postgresVersion: 16, + instances: [{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, + }], + }`) + + base.Namespace = namespace.Name + base.Name = "postgres-authentication-rules" + + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base cluster to be valid") + + var u unstructured.Unstructured + require.UnmarshalInto(t, &u, require.Value(yaml.Marshal(base))) + assert.Equal(t, u.GetAPIVersion(), "postgres-operator.crunchydata.com/v1beta1") + + testPostgresAuthenticationCommon(t, cc, u) +} + +func TestPostgresAuthenticationV1(t *testing.T) { + ctx := t.Context() + cc := require.KubernetesAtLeast(t, "1.30") + t.Parallel() + + namespace := require.Namespace(t, cc) + base := v1.NewPostgresCluster() + + // required fields + require.UnmarshalInto(t, &base.Spec, `{ + postgresVersion: 16, + instances: [{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, + }], + }`) + + base.Namespace = namespace.Name + base.Name = "postgres-authentication-rules" + + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base cluster to be valid") + + var u unstructured.Unstructured + require.UnmarshalInto(t, &u, require.Value(yaml.Marshal(base))) + assert.Equal(t, u.GetAPIVersion(), "postgres-operator.crunchydata.com/v1") + + testPostgresAuthenticationCommon(t, cc, u) +} + +func testPostgresAuthenticationCommon(t *testing.T, cc client.Client, base unstructured.Unstructured) { + ctx := t.Context() + + t.Run("OneTopLevel", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { connection: host, hba: anything }, + { users: [alice, bob], hba: anything }, + ], + }`, "spec", "authentication") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 2)) + + for i, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d]", i)) + assert.Assert(t, cmp.Contains(cause.Message, "cannot be combined")) + } + }) + + t.Run("NoInclude", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { hba: 'include "/etc/passwd"' }, + { hba: ' include_dir /tmp' }, + { hba: 'include_if_exists postgresql.auto.conf' }, + ], + }`, "spec", "authentication") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 3)) + + for i, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d].hba", i)) + assert.Assert(t, cmp.Contains(cause.Message, "cannot include")) + } + }) + + t.Run("NoStructuredTrust", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { connection: local, method: trust }, + { connection: hostssl, method: trust }, + { connection: hostgssenc, method: trust }, + ], + }`, "spec", "authentication") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 3)) + + for i, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d].method", i)) + assert.Assert(t, cmp.Contains(cause.Message, "unsafe")) + } + }) + + t.Run("LDAP", func(t *testing.T) { + t.Run("Required", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { connection: hostssl, method: ldap }, + { connection: hostssl, method: ldap, options: {} }, + { connection: hostssl, method: ldap, options: { ldapbinddn: any } }, + ], + }`, "spec", "authentication") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 3)) + + for i, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d]", i), "%#v", cause) + assert.Assert(t, cmp.Contains(cause.Message, `"ldap" method requires`)) + } + + // These are valid. + + unstructured.RemoveNestedField(cluster.Object, "spec", "authentication") + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { connection: hostssl, method: ldap, options: { ldapbasedn: any } }, + { connection: hostssl, method: ldap, options: { ldapprefix: any } }, + { connection: hostssl, method: ldap, options: { ldapsuffix: any } }, + ], + }`, "spec", "authentication") + assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) + }) + + t.Run("Mixed", func(t *testing.T) { + // Some options cannot be combined with others. + + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { connection: hostssl, method: ldap, options: { ldapbinddn: any, ldapprefix: other } }, + { connection: hostssl, method: ldap, options: { ldapbasedn: any, ldapsuffix: other } }, + ], + }`, "spec", "authentication") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 2)) + + for i, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d]", i), "%#v", cause) + assert.Assert(t, cmp.Regexp(`cannot use .+? options with .+? options`, cause.Message)) + } + + // These combinations are allowed. + + unstructured.RemoveNestedField(cluster.Object, "spec", "authentication") + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { connection: hostssl, method: ldap, options: { ldapprefix: one, ldapsuffix: two } }, + { connection: hostssl, method: ldap, options: { ldapbasedn: one, ldapbinddn: two } }, + { connection: hostssl, method: ldap, options: { + ldapbasedn: one, ldapsearchattribute: two, ldapsearchfilter: three, + } }, + ], + }`, "spec", "authentication") + assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) + }) + }) + + t.Run("RADIUS", func(t *testing.T) { + t.Run("Required", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { connection: hostssl, method: radius }, + { connection: hostssl, method: radius, options: {} }, + { connection: hostssl, method: radius, options: { radiusidentifiers: any } }, + { connection: hostssl, method: radius, options: { radiusservers: any } }, + { connection: hostssl, method: radius, options: { radiussecrets: any } }, + ], + }`, "spec", "authentication") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 5)) + + for i, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, fmt.Sprintf("spec.authentication.rules[%d]", i), "%#v", cause) + assert.Assert(t, cmp.Contains(cause.Message, `"radius" method requires`)) + } + + // These are valid. + + unstructured.RemoveNestedField(cluster.Object, "spec", "authentication") + require.UnmarshalIntoField(t, cluster, `{ + rules: [ + { connection: hostssl, method: radius, options: { radiusservers: one, radiussecrets: two } }, + { connection: hostssl, method: radius, options: { + radiusservers: one, radiussecrets: two, radiusports: three, + } }, + ], + }`, "spec", "authentication") + assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) + }) + }) +} diff --git a/internal/testing/validation/postgrescluster/postgres_config_test.go b/internal/testing/validation/postgrescluster/postgres_config_test.go new file mode 100644 index 0000000000..b03ed29713 --- /dev/null +++ b/internal/testing/validation/postgrescluster/postgres_config_test.go @@ -0,0 +1,288 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "testing" + + "gotest.tools/v3/assert" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" + v1 "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestPostgresConfigParametersV1beta1(t *testing.T) { + ctx := t.Context() + cc := require.Kubernetes(t) + t.Parallel() + + namespace := require.Namespace(t, cc) + base := v1beta1.NewPostgresCluster() + + // required fields + require.UnmarshalInto(t, &base.Spec, `{ + postgresVersion: 16, + instances: [{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, + }], + }`) + + base.Namespace = namespace.Name + base.Name = "postgres-config-parameters" + + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base cluster to be valid") + + var u unstructured.Unstructured + require.UnmarshalInto(t, &u, require.Value(yaml.Marshal(base))) + assert.Equal(t, u.GetAPIVersion(), "postgres-operator.crunchydata.com/v1beta1") + + testPostgresConfigParametersCommon(t, cc, u) +} + +func TestPostgresConfigParametersV1(t *testing.T) { + ctx := t.Context() + cc := require.KubernetesAtLeast(t, "1.30") + t.Parallel() + + namespace := require.Namespace(t, cc) + base := v1.NewPostgresCluster() + + // required fields + require.UnmarshalInto(t, &base.Spec, `{ + postgresVersion: 16, + instances: [{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, + }], + }`) + + base.Namespace = namespace.Name + base.Name = "postgres-config-parameters" + + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base cluster to be valid") + + var u unstructured.Unstructured + require.UnmarshalInto(t, &u, require.Value(yaml.Marshal(base))) + assert.Equal(t, u.GetAPIVersion(), "postgres-operator.crunchydata.com/v1") + + testPostgresConfigParametersCommon(t, cc, u) +} + +func testPostgresConfigParametersCommon(t *testing.T, cc client.Client, base unstructured.Unstructured) { + ctx := t.Context() + + t.Run("Allowed", func(t *testing.T) { + for _, tt := range []struct { + key string + value any + }{ + {"archive_timeout", 100}, + {"archive_timeout", "20s"}, + } { + t.Run(tt.key, func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal(tt.value)), + "spec", "config", "parameters", tt.key) + + assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) + }) + } + }) + + t.Run("Disallowed", func(t *testing.T) { + for _, tt := range []struct { + key string + value any + }{ + {key: "cluster_name", value: "asdf"}, + {key: "config_file", value: "asdf"}, + {key: "data_directory", value: ""}, + {key: "external_pid_file", value: ""}, + {key: "hba_file", value: "one"}, + {key: "hot_standby", value: "off"}, + {key: "ident_file", value: "two"}, + {key: "listen_addresses", value: ""}, + {key: "port", value: 5}, + {key: "wal_log_hints", value: "off"}, + } { + t.Run(tt.key, func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal(tt.value)), + "spec", "config", "parameters", tt.key) + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 1)) + + // TODO(k8s-1.30) TODO(validation): Move the parameter name from the message to the field path. + assert.Equal(t, status.Details.Causes[0].Field, "spec.config.parameters") + assert.Assert(t, cmp.Contains(status.Details.Causes[0].Message, tt.key)) + }) + } + }) + + t.Run("Logging", func(t *testing.T) { + for _, tt := range []struct { + valid bool + key string + value any + message string + }{ + {valid: false, key: "log_file_mode", value: "", message: "cannot be changed"}, + {valid: false, key: "log_file_mode", value: "any", message: "cannot be changed"}, + {valid: false, key: "logging_collector", value: "", message: "unsafe"}, + {valid: false, key: "logging_collector", value: "off", message: "unsafe"}, + {valid: false, key: "logging_collector", value: "on", message: "unsafe"}, + + {valid: true, key: "log_destination", value: "anything"}, + {valid: true, key: "log_directory", value: "anything"}, + {valid: true, key: "log_filename", value: "anything"}, + {valid: true, key: "log_filename", value: "percent-%s-too"}, + {valid: true, key: "log_rotation_age", value: "7d"}, + {valid: true, key: "log_rotation_age", value: 5}, + {valid: true, key: "log_rotation_size", value: "100MB"}, + {valid: true, key: "log_rotation_size", value: 13}, + {valid: true, key: "log_timezone", value: ""}, + {valid: true, key: "log_timezone", value: "nonsense"}, + } { + t.Run(fmt.Sprint(tt), func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal(tt.value)), + "spec", "config", "parameters", tt.key) + + err := cc.Create(ctx, cluster, client.DryRunAll) + + if tt.valid { + assert.NilError(t, err) + assert.Equal(t, "", tt.message, "BUG IN TEST: no message expected when valid") + } else { + assert.Assert(t, apierrors.IsInvalid(err)) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 1)) + + // TODO(k8s-1.30) TODO(validation): Move the parameter name from the message to the field path. + assert.Equal(t, status.Details.Causes[0].Field, "spec.config.parameters") + assert.Assert(t, cmp.Contains(status.Details.Causes[0].Message, tt.key)) + assert.Assert(t, cmp.Contains(status.Details.Causes[0].Message, tt.message)) + } + }) + } + }) + + t.Run("NoConnections", func(t *testing.T) { + for _, tt := range []struct { + key string + value any + }{ + {key: "ssl", value: "off"}, + {key: "ssl_ca_file", value: ""}, + {key: "unix_socket_directories", value: "one"}, + {key: "unix_socket_group", value: "two"}, + } { + t.Run(tt.key, func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal(tt.value)), + "spec", "config", "parameters", tt.key) + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + }) + } + }) + + t.Run("NoWriteAheadLog", func(t *testing.T) { + for _, tt := range []struct { + key string + value any + }{ + {key: "archive_mode", value: "off"}, + {key: "archive_command", value: "true"}, + {key: "restore_command", value: "true"}, + {key: "recovery_target", value: "immediate"}, + {key: "recovery_target_name", value: "doot"}, + } { + t.Run(tt.key, func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal(tt.value)), + "spec", "config", "parameters", tt.key) + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + }) + } + }) + + t.Run("wal_level", func(t *testing.T) { + t.Run("Valid", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + `logical`, "spec", "config", "parameters", "wal_level") + + assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) + }) + + t.Run("Invalid", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + `minimal`, "spec", "config", "parameters", "wal_level") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, `"replica" or higher`) + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 1)) + assert.Equal(t, status.Details.Causes[0].Field, "spec.config.parameters") + assert.Assert(t, cmp.Contains(status.Details.Causes[0].Message, "wal_level")) + }) + }) + + t.Run("NoReplication", func(t *testing.T) { + for _, tt := range []struct { + key string + value any + }{ + {key: "synchronous_standby_names", value: ""}, + {key: "primary_conninfo", value: ""}, + {key: "primary_slot_name", value: ""}, + {key: "recovery_min_apply_delay", value: ""}, + } { + t.Run(tt.key, func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal(tt.value)), + "spec", "config", "parameters", tt.key) + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + }) + } + }) +} diff --git a/internal/testing/validation/postgrescluster/postgres_users_test.go b/internal/testing/validation/postgrescluster/postgres_users_test.go new file mode 100644 index 0000000000..4bb6ca52a4 --- /dev/null +++ b/internal/testing/validation/postgrescluster/postgres_users_test.go @@ -0,0 +1,168 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "testing" + + "gotest.tools/v3/assert" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/crunchydata/postgres-operator/internal/testing/cmp" + "github.com/crunchydata/postgres-operator/internal/testing/require" + v1 "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1" + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestPostgresUserOptionsV1beta1(t *testing.T) { + ctx := t.Context() + cc := require.Kubernetes(t) + t.Parallel() + + namespace := require.Namespace(t, cc) + base := v1beta1.NewPostgresCluster() + + // required fields + require.UnmarshalInto(t, &base.Spec, `{ + postgresVersion: 16, + instances: [{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, + }], + }`) + + base.Namespace = namespace.Name + base.Name = "postgres-user-options" + + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base cluster to be valid") + + var u unstructured.Unstructured + require.UnmarshalInto(t, &u, require.Value(yaml.Marshal(base))) + assert.Equal(t, u.GetAPIVersion(), "postgres-operator.crunchydata.com/v1beta1") + + testPostgresUserOptionsCommon(t, cc, u) +} + +func TestPostgresUserOptionsV1(t *testing.T) { + ctx := t.Context() + cc := require.KubernetesAtLeast(t, "1.30") + t.Parallel() + + namespace := require.Namespace(t, cc) + base := v1.NewPostgresCluster() + + // required fields + require.UnmarshalInto(t, &base.Spec, `{ + postgresVersion: 16, + instances: [{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, + }], + }`) + + base.Namespace = namespace.Name + base.Name = "postgres-user-options" + + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base cluster to be valid") + + var u unstructured.Unstructured + require.UnmarshalInto(t, &u, require.Value(yaml.Marshal(base))) + assert.Equal(t, u.GetAPIVersion(), "postgres-operator.crunchydata.com/v1") + + testPostgresUserOptionsCommon(t, cc, u) +} + +func testPostgresUserOptionsCommon(t *testing.T, cc client.Client, base unstructured.Unstructured) { + ctx := t.Context() + + // See [internal/controller/postgrescluster.TestValidatePostgresUsers] + + t.Run("NoComments", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal([]v1beta1.PostgresUserSpec{ + {Name: "dashes", Options: "ANY -- comment"}, + {Name: "block-open", Options: "/* asdf"}, + {Name: "block-close", Options: " qw */ rt"}, + })), + "spec", "users") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "cannot contain comments") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 3)) + + for i, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, fmt.Sprintf("spec.users[%d].options", i)) + assert.Assert(t, cmp.Contains(cause.Message, "cannot contain comments")) + } + }) + + t.Run("NoPassword", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal([]v1beta1.PostgresUserSpec{ + {Name: "uppercase", Options: "SUPERUSER PASSWORD ''"}, + {Name: "lowercase", Options: "password 'asdf'"}, + })), + "spec", "users") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "cannot assign password") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 2)) + + for i, cause := range status.Details.Causes { + assert.Equal(t, cause.Field, fmt.Sprintf("spec.users[%d].options", i)) + assert.Assert(t, cmp.Contains(cause.Message, "cannot assign password")) + } + }) + + t.Run("NoTerminators", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal([]v1beta1.PostgresUserSpec{ + {Name: "semicolon", Options: "some ;where"}, + })), + "spec", "users") + + err := cc.Create(ctx, cluster, client.DryRunAll) + assert.Assert(t, apierrors.IsInvalid(err)) + assert.ErrorContains(t, err, "should match") + + status := require.StatusError(t, err) + assert.Assert(t, status.Details != nil) + assert.Assert(t, cmp.Len(status.Details.Causes, 1)) + assert.Equal(t, status.Details.Causes[0].Field, "spec.users[0].options") + }) + + t.Run("Valid", func(t *testing.T) { + cluster := base.DeepCopy() + require.UnmarshalIntoField(t, cluster, + require.Value(yaml.Marshal([]v1beta1.PostgresUserSpec{ + {Name: "normal", Options: "CREATEDB valid until '2006-01-02'"}, + {Name: "very-full", Options: "NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOLOGIN NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 5"}, + })), + "spec", "users") + + assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) + }) +} diff --git a/internal/testing/validation/postgrescluster_test.go b/internal/testing/validation/postgrescluster_test.go index e71ff22b2e..a4c052ee8f 100644 --- a/internal/testing/validation/postgrescluster_test.go +++ b/internal/testing/validation/postgrescluster_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,120 +6,100 @@ package validation import ( "context" - "fmt" "testing" "gotest.tools/v3/assert" - apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/yaml" - "github.com/crunchydata/postgres-operator/internal/testing/cmp" "github.com/crunchydata/postgres-operator/internal/testing/require" + v1 "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" ) -func TestPostgresUserOptions(t *testing.T) { +func TestPostgresUserInterfaceAcrossVersions(t *testing.T) { ctx := context.Background() cc := require.Kubernetes(t) t.Parallel() namespace := require.Namespace(t, cc) - base := v1beta1.NewPostgresCluster() + base := v1beta1.NewPostgresCluster() // Start with a bunch of required fields. - assert.NilError(t, yaml.Unmarshal([]byte(`{ - postgresVersion: 16, - backups: { - pgbackrest: { - repos: [{ name: repo1 }], + base.Namespace = namespace.Name + base.Name = "postgres-pgadmin" + require.UnmarshalInto(t, &base.Spec, `{ + userInterface: { + pgAdmin: { + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, }, }, + postgresVersion: 16, instances: [{ dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Mi } }, }, }], - }`), &base.Spec)) - - base.Namespace = namespace.Name - base.Name = "postgres-user-options" - - assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), - "expected this base cluster to be valid") - - // See [internal/controller/postgrescluster.TestValidatePostgresUsers] - - t.Run("NoComments", func(t *testing.T) { - cluster := base.DeepCopy() - cluster.Spec.Users = []v1beta1.PostgresUserSpec{ - {Name: "dashes", Options: "ANY -- comment"}, - {Name: "block-open", Options: "/* asdf"}, - {Name: "block-close", Options: " qw */ rt"}, - } - - err := cc.Create(ctx, cluster, client.DryRunAll) - assert.Assert(t, apierrors.IsInvalid(err)) - assert.ErrorContains(t, err, "cannot contain comments") - - //nolint:errorlint // This is a test, and a panic is unlikely. - status := err.(apierrors.APIStatus).Status() - assert.Assert(t, status.Details != nil) - assert.Equal(t, len(status.Details.Causes), 3) - - for i, cause := range status.Details.Causes { - assert.Equal(t, cause.Field, fmt.Sprintf("spec.users[%d].options", i)) - assert.Assert(t, cmp.Contains(cause.Message, "cannot contain comments")) - } - }) - - t.Run("NoPassword", func(t *testing.T) { - cluster := base.DeepCopy() - cluster.Spec.Users = []v1beta1.PostgresUserSpec{ - {Name: "uppercase", Options: "SUPERUSER PASSWORD ''"}, - {Name: "lowercase", Options: "password 'asdf'"}, - } + }`) - err := cc.Create(ctx, cluster, client.DryRunAll) - assert.Assert(t, apierrors.IsInvalid(err)) - assert.ErrorContains(t, err, "cannot assign password") - - //nolint:errorlint // This is a test, and a panic is unlikely. - status := err.(apierrors.APIStatus).Status() - assert.Assert(t, status.Details != nil) - assert.Equal(t, len(status.Details.Causes), 2) + v1base := v1.NewPostgresCluster() + // Start with a bunch of required fields. + v1base.Namespace = namespace.Name + v1base.Name = "postgres-pgadmin" + require.UnmarshalInto(t, &v1base.Spec, `{ + userInterface: { + pgAdmin: { + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, + }, + }, + postgresVersion: 16, + instances: [{ + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce], + resources: { requests: { storage: 1Mi } }, + }, + }], + }`) - for i, cause := range status.Details.Causes { - assert.Equal(t, cause.Field, fmt.Sprintf("spec.users[%d].options", i)) - assert.Assert(t, cmp.Contains(cause.Message, "cannot assign password")) - } + t.Run("v1beta1 is valid with pgadmin", func(t *testing.T) { + assert.NilError(t, cc.Create(ctx, base.DeepCopy(), client.DryRunAll), + "expected this base cluster to be valid") }) - - t.Run("NoTerminators", func(t *testing.T) { - cluster := base.DeepCopy() - cluster.Spec.Users = []v1beta1.PostgresUserSpec{ - {Name: "semicolon", Options: "some ;where"}, - } - - err := cc.Create(ctx, cluster, client.DryRunAll) - assert.Assert(t, apierrors.IsInvalid(err)) - assert.ErrorContains(t, err, "should match") - - //nolint:errorlint // This is a test, and a panic is unlikely. - status := err.(apierrors.APIStatus).Status() - assert.Assert(t, status.Details != nil) - assert.Equal(t, len(status.Details.Causes), 1) - assert.Equal(t, status.Details.Causes[0].Field, "spec.users[0].options") + t.Run("v1 is invalid with pgadmin", func(t *testing.T) { + assert.ErrorContains(t, cc.Create(ctx, v1base.DeepCopy(), client.DryRunAll), + "userInterface not available in v1") }) - t.Run("Valid", func(t *testing.T) { - cluster := base.DeepCopy() - cluster.Spec.Users = []v1beta1.PostgresUserSpec{ - {Name: "normal", Options: "CREATEDB valid until '2006-01-02'"}, - {Name: "very-full", Options: "NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOLOGIN NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 5"}, - } + t.Run("v1 is valid with pgadmin but only if unchanged from v1beta1", func(t *testing.T) { + // Validation ratcheting is enabled starting in Kubernetes 1.30 + require.KubernetesAtLeast(t, "1.30") + + // A v1 that has been updated from a v1beta1 with no change to the userInterface is valid + assert.NilError(t, cc.Create(ctx, base), + "expected this base cluster to be valid") + v1base.ResourceVersion = base.ResourceVersion + assert.NilError(t, cc.Update(ctx, v1base), + "expected this v1 cluster to be a valid update") + + // But will not be valid if there's a change to the userInterface + require.UnmarshalInto(t, &v1base.Spec, `{ + userInterface: { + pgAdmin: { + dataVolumeClaimSpec: { + accessModes: [ReadWriteOnce, ReadWriteMany], + resources: { requests: { storage: 2Mi } }, + }, + }, + }, + }`) - assert.NilError(t, cc.Create(ctx, cluster, client.DryRunAll)) + assert.ErrorContains(t, cc.Update(ctx, v1base), + "userInterface not available in v1") }) } diff --git a/internal/tracing/errors.go b/internal/tracing/errors.go new file mode 100644 index 0000000000..7ad54aa12e --- /dev/null +++ b/internal/tracing/errors.go @@ -0,0 +1,34 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package tracing + +import ( + semconv "go.opentelemetry.io/otel/semconv/v1.27.0" + "go.opentelemetry.io/otel/trace" +) + +// Check returns true when err is nil. Otherwise, it adds err as an exception +// event on s and returns false. If you intend to return err, consider using +// [Escape] instead. +// +// See: https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-spans +func Check(s Span, err error) bool { + if err == nil { + return true + } + if s.IsRecording() { + s.RecordError(err) + } + return false +} + +// Escape adds non-nil err as an escaped exception event on s and returns err. +// See: https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-spans +func Escape(s Span, err error) error { + if err != nil && s.IsRecording() { + s.RecordError(err, trace.WithAttributes(semconv.ExceptionEscaped(true))) + } + return err +} diff --git a/internal/tracing/errors_test.go b/internal/tracing/errors_test.go new file mode 100644 index 0000000000..2bfc6a8aa8 --- /dev/null +++ b/internal/tracing/errors_test.go @@ -0,0 +1,94 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package tracing + +import ( + "context" + "errors" + "testing" + + "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + semconv "go.opentelemetry.io/otel/semconv/v1.27.0" + "gotest.tools/v3/assert" +) + +func TestCheck(t *testing.T) { + recorder := tracetest.NewSpanRecorder() + tracer := trace.NewTracerProvider( + trace.WithSpanProcessor(recorder), + ).Tracer("") + + { + _, span := tracer.Start(context.Background(), "") + assert.Assert(t, Check(span, nil)) + span.End() + + spans := recorder.Ended() + assert.Equal(t, len(spans), 1) + assert.Equal(t, len(spans[0].Events()), 0, "expected no events") + } + + { + _, span := tracer.Start(context.Background(), "") + assert.Assert(t, !Check(span, errors.New("msg"))) + span.End() + + spans := recorder.Ended() + assert.Equal(t, len(spans), 2) + assert.Equal(t, len(spans[1].Events()), 1, "expected one event") + + event := spans[1].Events()[0] + assert.Equal(t, event.Name, semconv.ExceptionEventName) + + attrs := event.Attributes + assert.Equal(t, len(attrs), 2) + assert.Equal(t, string(attrs[0].Key), "exception.type") + assert.Equal(t, string(attrs[1].Key), "exception.message") + assert.Equal(t, attrs[0].Value.AsInterface(), "*errors.errorString") + assert.Equal(t, attrs[1].Value.AsInterface(), "msg") + } +} + +func TestEscape(t *testing.T) { + recorder := tracetest.NewSpanRecorder() + tracer := trace.NewTracerProvider( + trace.WithSpanProcessor(recorder), + ).Tracer("") + + { + _, span := tracer.Start(context.Background(), "") + assert.NilError(t, Escape(span, nil)) + span.End() + + spans := recorder.Ended() + assert.Equal(t, len(spans), 1) + assert.Equal(t, len(spans[0].Events()), 0, "expected no events") + } + + { + _, span := tracer.Start(context.Background(), "") + expected := errors.New("somesuch") + assert.Assert(t, errors.Is(Escape(span, expected), expected), + "expected to unwrap the original error") + span.End() + + spans := recorder.Ended() + assert.Equal(t, len(spans), 2) + assert.Equal(t, len(spans[1].Events()), 1, "expected one event") + + event := spans[1].Events()[0] + assert.Equal(t, event.Name, semconv.ExceptionEventName) + + attrs := event.Attributes + assert.Equal(t, len(attrs), 3) + assert.Equal(t, string(attrs[0].Key), "exception.escaped") + assert.Equal(t, string(attrs[1].Key), "exception.type") + assert.Equal(t, string(attrs[2].Key), "exception.message") + assert.Equal(t, attrs[0].Value.AsInterface(), true) + assert.Equal(t, attrs[1].Value.AsInterface(), "*errors.errorString") + assert.Equal(t, attrs[2].Value.AsInterface(), "somesuch") + } +} diff --git a/internal/tracing/tracing.go b/internal/tracing/tracing.go new file mode 100644 index 0000000000..59023de118 --- /dev/null +++ b/internal/tracing/tracing.go @@ -0,0 +1,65 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package tracing + +import ( + "context" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + semconv "go.opentelemetry.io/otel/semconv/v1.27.0" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// https://pkg.go.dev/go.opentelemetry.io/otel/trace +type ( + Span = trace.Span + Tracer = trace.Tracer +) + +var global = noop.NewTracerProvider().Tracer("") + +// SetDefaultTracer replaces the default Tracer with t. Before this is called, +// the default Tracer is a no-op. +func SetDefaultTracer(t Tracer) { global = t } + +type tracerKey struct{} + +// FromContext returns the Tracer stored by a prior call to [WithTracer] or [SetDefaultTracer]. +func FromContext(ctx context.Context) Tracer { + if t, ok := ctx.Value(tracerKey{}).(Tracer); ok { + return t + } + return global +} + +// NewContext returns a copy of ctx containing t. Retrieve it using [FromContext]. +func NewContext(ctx context.Context, t Tracer) context.Context { + return context.WithValue(ctx, tracerKey{}, t) +} + +// New returns a Tracer produced by [otel.GetTracerProvider]. +func New(name string, opts ...trace.TracerOption) Tracer { + opts = append([]trace.TracerOption{ + trace.WithSchemaURL(semconv.SchemaURL), + }, opts...) + + return otel.GetTracerProvider().Tracer(name, opts...) +} + +// Start creates a Span and a Context containing it. It uses the Tracer returned by [FromContext]. +func Start(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, Span) { + return FromContext(ctx).Start(ctx, name, opts...) +} + +// Bool sets the k attribute of s to v. +func Bool(s Span, k string, v bool) { s.SetAttributes(attribute.Bool(k, v)) } + +// Int sets the k attribute of s to v. +func Int(s Span, k string, v int) { s.SetAttributes(attribute.Int(k, v)) } + +// String sets the k attribute of s to v. +func String(s Span, k, v string) { s.SetAttributes(attribute.String(k, v)) } diff --git a/internal/tracing/tracing_test.go b/internal/tracing/tracing_test.go new file mode 100644 index 0000000000..4cd92d921e --- /dev/null +++ b/internal/tracing/tracing_test.go @@ -0,0 +1,110 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package tracing + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + semconv "go.opentelemetry.io/otel/semconv/v1.27.0" + "gotest.tools/v3/assert" +) + +func TestDefaultTracer(t *testing.T) { + ctx := context.Background() + + t.Run("no-op", func(t *testing.T) { + tracer := FromContext(ctx) + _, s1 := tracer.Start(ctx, "asdf") + defer s1.End() + assert.Assert(t, !s1.IsRecording()) + + _, s2 := Start(ctx, "doot") + defer s2.End() + assert.Assert(t, !s2.IsRecording()) + }) + + t.Run("set", func(t *testing.T) { + prior := global + t.Cleanup(func() { SetDefaultTracer(prior) }) + + recorder := tracetest.NewSpanRecorder() + SetDefaultTracer(trace.NewTracerProvider( + trace.WithSpanProcessor(recorder), + ).Tracer("myst")) + + _, span := Start(ctx, "zork") + span.End() + + spans := recorder.Ended() + assert.Equal(t, len(spans), 1) + assert.Equal(t, spans[0].InstrumentationScope().Name, "myst") + assert.Equal(t, spans[0].Name(), "zork") + }) +} + +func TestNew(t *testing.T) { + prior := otel.GetTracerProvider() + t.Cleanup(func() { otel.SetTracerProvider(prior) }) + + recorder := tracetest.NewSpanRecorder() + otel.SetTracerProvider(trace.NewTracerProvider( + trace.WithSpanProcessor(recorder), + )) + + _, span := New("onetwo").Start(context.Background(), "three") + span.End() + + spans := recorder.Ended() + assert.Equal(t, len(spans), 1) + assert.Equal(t, spans[0].InstrumentationScope().Name, "onetwo") + assert.Equal(t, spans[0].InstrumentationScope().SchemaURL, semconv.SchemaURL) + assert.Equal(t, spans[0].Name(), "three") +} + +func TestFromContext(t *testing.T) { + recorder := tracetest.NewSpanRecorder() + + ctx := NewContext(context.Background(), trace.NewTracerProvider( + trace.WithSpanProcessor(recorder), + ).Tracer("something")) + + _, span := Start(ctx, "spanspan") + span.End() + + spans := recorder.Ended() + assert.Equal(t, len(spans), 1) + assert.Equal(t, spans[0].InstrumentationScope().Name, "something") + assert.Equal(t, spans[0].Name(), "spanspan") +} + +func TestAttributes(t *testing.T) { + recorder := tracetest.NewSpanRecorder() + + ctx := NewContext(context.Background(), trace.NewTracerProvider( + trace.WithSpanProcessor(recorder), + ).Tracer("")) + + _, span := Start(ctx, "") + Bool(span, "aa", true) + Int(span, "abc", 99) + String(span, "xyz", "copy pasta") + span.End() + + spans := recorder.Ended() + assert.Equal(t, len(spans), 1) + assert.Equal(t, len(spans[0].Attributes()), 3) + + attrs := spans[0].Attributes() + assert.Equal(t, string(attrs[0].Key), "aa") + assert.Equal(t, string(attrs[1].Key), "abc") + assert.Equal(t, string(attrs[2].Key), "xyz") + assert.Equal(t, attrs[0].Value.AsInterface(), true) + assert.Equal(t, attrs[1].Value.AsInterface(), int64(99)) + assert.Equal(t, attrs[2].Value.AsInterface(), "copy pasta") +} diff --git a/internal/upgradecheck/header.go b/internal/upgradecheck/header.go index a1d56ef442..f2449f909b 100644 --- a/internal/upgradecheck/header.go +++ b/internal/upgradecheck/header.go @@ -1,4 +1,4 @@ -// Copyright 2017 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -14,12 +14,11 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/util/uuid" - "k8s.io/client-go/discovery" - "k8s.io/client-go/rest" crclient "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crunchydata/postgres-operator/internal/controller/postgrescluster" "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/kubernetes" "github.com/crunchydata/postgres-operator/internal/logging" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" @@ -51,16 +50,16 @@ type clientUpgradeData struct { // generateHeader aggregates data and returns a struct of that data // If any errors are encountered, it logs those errors and uses the default values -func generateHeader(ctx context.Context, cfg *rest.Config, crClient crclient.Client, - pgoVersion string, isOpenShift bool, registrationToken string) *clientUpgradeData { +func generateHeader(ctx context.Context, crClient crclient.Client, + pgoVersion string, registrationToken string) *clientUpgradeData { return &clientUpgradeData{ BridgeClustersTotal: getBridgeClusters(ctx, crClient), BuildSource: os.Getenv("BUILD_SOURCE"), DeploymentID: ensureDeploymentID(ctx, crClient), - FeatureGatesEnabled: feature.ShowGates(ctx), - IsOpenShift: isOpenShift, - KubernetesEnv: getServerVersion(ctx, cfg), + FeatureGatesEnabled: feature.ShowEnabled(ctx), + IsOpenShift: kubernetes.IsOpenShift(ctx), + KubernetesEnv: kubernetes.VersionString(ctx), PGOClustersTotal: getManagedClusters(ctx, crClient), PGOInstaller: os.Getenv("PGO_INSTALLER"), PGOInstallerOrigin: os.Getenv("PGO_INSTALLER_ORIGIN"), @@ -189,31 +188,8 @@ func getBridgeClusters(ctx context.Context, crClient crclient.Client) int { return count } -// getServerVersion returns the stringified server version (i.e., the same info `kubectl version` -// returns for the server) -// Any errors encountered will be logged and will return an empty string -func getServerVersion(ctx context.Context, cfg *rest.Config) string { - log := logging.FromContext(ctx) - discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) - if err != nil { - log.V(1).Info("upgrade check issue: could not retrieve discovery client", - "response", err.Error()) - return "" - } - versionInfo, err := discoveryClient.ServerVersion() - if err != nil { - log.V(1).Info("upgrade check issue: could not retrieve server version", - "response", err.Error()) - return "" - } - return versionInfo.String() -} - -func addHeader(req *http.Request, upgradeInfo *clientUpgradeData) (*http.Request, error) { - marshaled, err := json.Marshal(upgradeInfo) - if err == nil { - upgradeInfoString := string(marshaled) - req.Header.Add(clientHeader, upgradeInfoString) - } - return req, err +func addHeader(req *http.Request, upgradeInfo *clientUpgradeData) *http.Request { + marshaled, _ := json.Marshal(upgradeInfo) + req.Header.Add(clientHeader, string(marshaled)) + return req } diff --git a/internal/upgradecheck/header_test.go b/internal/upgradecheck/header_test.go index c144e7629b..40c3728ca0 100644 --- a/internal/upgradecheck/header_test.go +++ b/internal/upgradecheck/header_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -14,14 +14,9 @@ import ( "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/uuid" - "k8s.io/client-go/discovery" - // Google Kubernetes Engine / Google Cloud Platform authentication provider - _ "k8s.io/client-go/plugin/pkg/client/auth/gcp" - "k8s.io/client-go/rest" - - "github.com/crunchydata/postgres-operator/internal/controller/postgrescluster" "github.com/crunchydata/postgres-operator/internal/feature" + "github.com/crunchydata/postgres-operator/internal/kubernetes" "github.com/crunchydata/postgres-operator/internal/naming" "github.com/crunchydata/postgres-operator/internal/testing/cmp" "github.com/crunchydata/postgres-operator/internal/testing/require" @@ -32,17 +27,15 @@ func TestGenerateHeader(t *testing.T) { setupDeploymentID(t) ctx := context.Background() cfg, cc := require.Kubernetes2(t) - setupNamespace(t, cc) - dc, err := discovery.NewDiscoveryClientForConfig(cfg) - assert.NilError(t, err) - server, err := dc.ServerVersion() + discovery, err := kubernetes.NewDiscoveryRunner(cfg) assert.NilError(t, err) - - reconciler := postgrescluster.Reconciler{Client: cc} + assert.NilError(t, discovery.Read(ctx)) + ctx = kubernetes.NewAPIContext(ctx, discovery) t.Setenv("PGO_INSTALLER", "test") t.Setenv("PGO_INSTALLER_ORIGIN", "test-origin") + t.Setenv("PGO_NAMESPACE", require.Namespace(t, cc).Name) t.Setenv("BUILD_SOURCE", "developer") t.Run("error ensuring ID", func(t *testing.T) { @@ -51,11 +44,10 @@ func TestGenerateHeader(t *testing.T) { } ctx, calls := setupLogCapture(ctx) - res := generateHeader(ctx, cfg, fakeClientWithOptionalError, - "1.2.3", reconciler.IsOpenShift, "") + res := generateHeader(ctx, fakeClientWithOptionalError, "1.2.3", "") assert.Equal(t, len(*calls), 1) assert.Assert(t, cmp.Contains((*calls)[0], `upgrade check issue: could not apply configmap`)) - assert.Equal(t, res.IsOpenShift, reconciler.IsOpenShift) + assert.Equal(t, discovery.IsOpenShift(), res.IsOpenShift) assert.Equal(t, deploymentID, res.DeploymentID) pgoList := v1beta1.PostgresClusterList{} err := cc.List(ctx, &pgoList) @@ -66,7 +58,7 @@ func TestGenerateHeader(t *testing.T) { assert.NilError(t, err) assert.Equal(t, len(bridgeList.Items), res.BridgeClustersTotal) assert.Equal(t, "1.2.3", res.PGOVersion) - assert.Equal(t, server.String(), res.KubernetesEnv) + assert.Equal(t, discovery.Version().String(), res.KubernetesEnv) assert.Equal(t, "test", res.PGOInstaller) assert.Equal(t, "test-origin", res.PGOInstallerOrigin) assert.Equal(t, "developer", res.BuildSource) @@ -78,40 +70,18 @@ func TestGenerateHeader(t *testing.T) { } ctx, calls := setupLogCapture(ctx) - res := generateHeader(ctx, cfg, fakeClientWithOptionalError, - "1.2.3", reconciler.IsOpenShift, "") + res := generateHeader(ctx, fakeClientWithOptionalError, "1.2.3", "") assert.Equal(t, len(*calls), 2) // Aggregating the logs since we cannot determine which call will be first callsAggregate := strings.Join(*calls, " ") assert.Assert(t, cmp.Contains(callsAggregate, `upgrade check issue: could not count postgres clusters`)) assert.Assert(t, cmp.Contains(callsAggregate, `upgrade check issue: could not count bridge clusters`)) - assert.Equal(t, res.IsOpenShift, reconciler.IsOpenShift) + assert.Equal(t, discovery.IsOpenShift(), res.IsOpenShift) assert.Equal(t, deploymentID, res.DeploymentID) assert.Equal(t, 0, res.PGOClustersTotal) assert.Equal(t, 0, res.BridgeClustersTotal) assert.Equal(t, "1.2.3", res.PGOVersion) - assert.Equal(t, server.String(), res.KubernetesEnv) - assert.Equal(t, "test", res.PGOInstaller) - assert.Equal(t, "test-origin", res.PGOInstallerOrigin) - assert.Equal(t, "developer", res.BuildSource) - }) - - t.Run("error getting server version info", func(t *testing.T) { - ctx, calls := setupLogCapture(ctx) - badcfg := &rest.Config{} - - res := generateHeader(ctx, badcfg, cc, - "1.2.3", reconciler.IsOpenShift, "") - assert.Equal(t, len(*calls), 1) - assert.Assert(t, cmp.Contains((*calls)[0], `upgrade check issue: could not retrieve server version`)) - assert.Equal(t, res.IsOpenShift, reconciler.IsOpenShift) - assert.Equal(t, deploymentID, res.DeploymentID) - pgoList := v1beta1.PostgresClusterList{} - err := cc.List(ctx, &pgoList) - assert.NilError(t, err) - assert.Equal(t, len(pgoList.Items), res.PGOClustersTotal) - assert.Equal(t, "1.2.3", res.PGOVersion) - assert.Equal(t, "", res.KubernetesEnv) + assert.Equal(t, discovery.Version().String(), res.KubernetesEnv) assert.Equal(t, "test", res.PGOInstaller) assert.Equal(t, "test-origin", res.PGOInstallerOrigin) assert.Equal(t, "developer", res.BuildSource) @@ -125,18 +95,20 @@ func TestGenerateHeader(t *testing.T) { })) ctx = feature.NewContext(ctx, gate) - res := generateHeader(ctx, cfg, cc, - "1.2.3", reconciler.IsOpenShift, "") + res := generateHeader(ctx, cc, "1.2.3", "") assert.Equal(t, len(*calls), 0) - assert.Equal(t, res.IsOpenShift, reconciler.IsOpenShift) + assert.Equal(t, discovery.IsOpenShift(), res.IsOpenShift) assert.Equal(t, deploymentID, res.DeploymentID) pgoList := v1beta1.PostgresClusterList{} err := cc.List(ctx, &pgoList) assert.NilError(t, err) assert.Equal(t, len(pgoList.Items), res.PGOClustersTotal) assert.Equal(t, "1.2.3", res.PGOVersion) - assert.Equal(t, server.String(), res.KubernetesEnv) - assert.Equal(t, "TablespaceVolumes=true", res.FeatureGatesEnabled) + assert.Equal(t, discovery.Version().String(), res.KubernetesEnv) + assert.Check(t, strings.Contains( + res.FeatureGatesEnabled, + "TablespaceVolumes=true", + )) assert.Equal(t, "test", res.PGOInstaller) assert.Equal(t, "test-origin", res.PGOInstallerOrigin) assert.Equal(t, "developer", res.BuildSource) @@ -146,7 +118,7 @@ func TestGenerateHeader(t *testing.T) { func TestEnsureID(t *testing.T) { ctx := context.Background() cc := require.Kubernetes(t) - setupNamespace(t, cc) + t.Setenv("PGO_NAMESPACE", require.Namespace(t, cc).Name) t.Run("success, no id set in mem or configmap", func(t *testing.T) { deploymentID = "" @@ -282,7 +254,7 @@ func TestEnsureID(t *testing.T) { func TestManageUpgradeCheckConfigMap(t *testing.T) { ctx := context.Background() cc := require.Kubernetes(t) - setupNamespace(t, cc) + t.Setenv("PGO_NAMESPACE", require.Namespace(t, cc).Name) t.Run("no namespace given", func(t *testing.T) { ctx, calls := setupLogCapture(ctx) @@ -408,7 +380,7 @@ func TestManageUpgradeCheckConfigMap(t *testing.T) { func TestApplyConfigMap(t *testing.T) { ctx := context.Background() cc := require.Kubernetes(t) - setupNamespace(t, cc) + t.Setenv("PGO_NAMESPACE", require.Namespace(t, cc).Name) t.Run("successful create", func(t *testing.T) { cmRetrieved := &corev1.ConfigMap{} @@ -561,31 +533,6 @@ func TestGetBridgeClusters(t *testing.T) { }) } -func TestGetServerVersion(t *testing.T) { - t.Run("success", func(t *testing.T) { - expect, server := setupVersionServer(t, true) - ctx, calls := setupLogCapture(context.Background()) - - got := getServerVersion(ctx, &rest.Config{ - Host: server.URL, - }) - assert.Equal(t, len(*calls), 0) - assert.Equal(t, got, expect.String()) - }) - - t.Run("failure", func(t *testing.T) { - _, server := setupVersionServer(t, false) - ctx, calls := setupLogCapture(context.Background()) - - got := getServerVersion(ctx, &rest.Config{ - Host: server.URL, - }) - assert.Equal(t, len(*calls), 1) - assert.Assert(t, cmp.Contains((*calls)[0], `upgrade check issue: could not retrieve server version`)) - assert.Equal(t, got, "") - }) -} - func TestAddHeader(t *testing.T) { t.Run("successful", func(t *testing.T) { req := &http.Request{ @@ -596,12 +543,11 @@ func TestAddHeader(t *testing.T) { PGOVersion: versionString, } - result, err := addHeader(req, upgradeInfo) - assert.NilError(t, err) + result := addHeader(req, upgradeInfo) header := result.Header[clientHeader] passedThroughData := &clientUpgradeData{} - err = json.Unmarshal([]byte(header[0]), passedThroughData) + err := json.Unmarshal([]byte(header[0]), passedThroughData) assert.NilError(t, err) assert.Equal(t, passedThroughData.PGOVersion, "1.2.3") diff --git a/internal/upgradecheck/helpers_test.go b/internal/upgradecheck/helpers_test.go index 63184184db..5e83cffe2a 100644 --- a/internal/upgradecheck/helpers_test.go +++ b/internal/upgradecheck/helpers_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -6,19 +6,13 @@ package upgradecheck import ( "context" - "encoding/json" "fmt" - "net/http" - "net/http/httptest" "testing" "github.com/go-logr/logr/funcr" - "gotest.tools/v3/assert" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/uuid" - "k8s.io/apimachinery/pkg/version" crclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" @@ -49,8 +43,8 @@ func (f *fakeClientWithError) Get(ctx context.Context, key types.NamespacedName, // Once that gets fixed, we can test without envtest func (f *fakeClientWithError) Patch(ctx context.Context, obj crclient.Object, patch crclient.Patch, opts ...crclient.PatchOption) error { - switch { - case f.errorType == "patch error": + switch f.errorType { + case "patch error": return fmt.Errorf("patch error") default: return f.Client.Patch(ctx, obj, patch, opts...) @@ -119,31 +113,6 @@ func setupFakeClientWithPGOScheme(t *testing.T, includeCluster bool) crclient.Cl return fake.NewClientBuilder().WithScheme(runtime.Scheme).Build() } -// setupVersionServer sets up and tears down a server and version info for testing -func setupVersionServer(t *testing.T, works bool) (version.Info, *httptest.Server) { - t.Helper() - expect := version.Info{ - Major: "1", - Minor: "22", - GitCommit: "v1.22.2", - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, - req *http.Request) { - if works { - output, _ := json.Marshal(expect) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - // We don't need to check the error output from this - _, _ = w.Write(output) - } else { - w.WriteHeader(http.StatusBadRequest) - } - })) - t.Cleanup(server.Close) - - return expect, server -} - // setupLogCapture captures the logs and keeps count of the logs captured func setupLogCapture(ctx context.Context) (context.Context, *[]string) { calls := []string{} @@ -154,26 +123,3 @@ func setupLogCapture(ctx context.Context) (context.Context, *[]string) { }) return logging.NewContext(ctx, testlog), &calls } - -// setupNamespace creates a namespace that will be deleted by t.Cleanup. -// For upgradechecking, this namespace is set to `postgres-operator`, -// which sometimes is created by other parts of the testing apparatus, -// cf., the createnamespace call in `make check-envtest-existing`. -// When creation fails, it calls t.Fatal. The caller may delete the namespace -// at any time. -func setupNamespace(t testing.TB, cc crclient.Client) { - t.Helper() - ns := &corev1.Namespace{} - ns.Name = "postgres-operator" - ns.Labels = map[string]string{"postgres-operator-test": t.Name()} - - ctx := context.Background() - exists := &corev1.Namespace{} - assert.NilError(t, crclient.IgnoreNotFound( - cc.Get(ctx, crclient.ObjectKeyFromObject(ns), exists))) - if exists.Name != "" { - return - } - assert.NilError(t, cc.Create(ctx, ns)) - t.Cleanup(func() { assert.Check(t, crclient.IgnoreNotFound(cc.Delete(ctx, ns))) }) -} diff --git a/internal/upgradecheck/http.go b/internal/upgradecheck/http.go index 71a3c465c0..acb4da386b 100644 --- a/internal/upgradecheck/http.go +++ b/internal/upgradecheck/http.go @@ -1,4 +1,4 @@ -// Copyright 2017 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -13,7 +13,6 @@ import ( "github.com/golang-jwt/jwt/v5" "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/rest" crclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -66,18 +65,18 @@ func init() { } func checkForUpgrades(ctx context.Context, url, versionString string, backoff wait.Backoff, - crclient crclient.Client, cfg *rest.Config, - isOpenShift bool, registrationToken string) (message string, header string, err error) { + crclient crclient.Client, registrationToken string, +) (message string, header string, err error) { var headerPayloadStruct *clientUpgradeData // Prep request - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err == nil { // generateHeader always returns some sort of struct, using defaults/nil values // in case some of the checks return errors - headerPayloadStruct = generateHeader(ctx, cfg, crclient, - versionString, isOpenShift, registrationToken) - req, err = addHeader(req, headerPayloadStruct) + headerPayloadStruct = generateHeader(ctx, crclient, + versionString, registrationToken) + req = addHeader(req, headerPayloadStruct) } // wait.ExponentialBackoff will retry the func according to the backoff object until @@ -124,9 +123,7 @@ func checkForUpgrades(ctx context.Context, url, versionString string, backoff wa type CheckForUpgradesScheduler struct { Client crclient.Client - Config *rest.Config - OpenShift bool Refresh time.Duration RegistrationToken string URL, Version string @@ -138,7 +135,7 @@ type CheckForUpgradesScheduler struct { // so this token is always current; but if that restart behavior is changed, // we will want the upgrade mechanism to instantiate its own registration runner // or otherwise get the most recent token. -func ManagedScheduler(m manager.Manager, openshift bool, +func ManagedScheduler(m manager.Manager, url, version string, registrationToken *jwt.Token) error { if url == "" { url = upgradeCheckURL @@ -151,8 +148,6 @@ func ManagedScheduler(m manager.Manager, openshift bool, return m.Add(&CheckForUpgradesScheduler{ Client: m.GetClient(), - Config: m.GetConfig(), - OpenShift: openshift, Refresh: 24 * time.Hour, RegistrationToken: token, URL: url, @@ -191,7 +186,7 @@ func (s *CheckForUpgradesScheduler) check(ctx context.Context) { }() info, header, err := checkForUpgrades(ctx, - s.URL, s.Version, backoff, s.Client, s.Config, s.OpenShift, s.RegistrationToken) + s.URL, s.Version, backoff, s.Client, s.RegistrationToken) if err != nil { log.V(1).Info("could not complete upgrade check", "response", err.Error()) diff --git a/internal/upgradecheck/http_test.go b/internal/upgradecheck/http_test.go index 9535f942ea..ae84ba16c0 100644 --- a/internal/upgradecheck/http_test.go +++ b/internal/upgradecheck/http_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -8,7 +8,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "net/http" "strings" @@ -18,7 +17,6 @@ import ( "github.com/go-logr/logr/funcr" "gotest.tools/v3/assert" "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/manager" "github.com/crunchydata/postgres-operator/internal/feature" @@ -49,7 +47,6 @@ func (m *MockClient) Do(req *http.Request) (*http.Response, error) { func TestCheckForUpgrades(t *testing.T) { fakeClient := setupFakeClientWithPGOScheme(t, true) - cfg := &rest.Config{} ctx := logging.NewContext(context.Background(), logging.Discard()) gate := feature.NewGate() @@ -69,7 +66,8 @@ func TestCheckForUpgrades(t *testing.T) { assert.Equal(t, data.RegistrationToken, "speakFriend") assert.Equal(t, data.BridgeClustersTotal, 2) assert.Equal(t, data.PGOClustersTotal, 2) - assert.Equal(t, data.FeatureGatesEnabled, "TablespaceVolumes=true") + assert.Equal(t, data.FeatureGatesEnabled, + "AutoCreateUserSchema=true,InstanceSidecars=true,PGUpgradeCPUConcurrency=true,TablespaceVolumes=true") } t.Run("success", func(t *testing.T) { @@ -83,7 +81,7 @@ func TestCheckForUpgrades(t *testing.T) { } res, header, err := checkForUpgrades(ctx, "", "4.7.3", backoff, - fakeClient, cfg, false, "speakFriend") + fakeClient, "speakFriend") assert.NilError(t, err) assert.Equal(t, res, `{"pgo_versions":[{"tag":"v5.0.4"},{"tag":"v5.0.3"},{"tag":"v5.0.2"},{"tag":"v5.0.1"},{"tag":"v5.0.0"}]}`) checkData(t, header) @@ -98,7 +96,7 @@ func TestCheckForUpgrades(t *testing.T) { } res, header, err := checkForUpgrades(ctx, "", "4.7.3", backoff, - fakeClient, cfg, false, "speakFriend") + fakeClient, "speakFriend") // Two failed calls because of env var assert.Equal(t, counter, 2) assert.Equal(t, res, "") @@ -118,7 +116,7 @@ func TestCheckForUpgrades(t *testing.T) { } res, header, err := checkForUpgrades(ctx, "", "4.7.3", backoff, - fakeClient, cfg, false, "speakFriend") + fakeClient, "speakFriend") assert.Equal(t, res, "") // Two failed calls because of env var assert.Equal(t, counter, 2) @@ -147,7 +145,7 @@ func TestCheckForUpgrades(t *testing.T) { } res, header, err := checkForUpgrades(ctx, "", "4.7.3", backoff, - fakeClient, cfg, false, "speakFriend") + fakeClient, "speakFriend") assert.Equal(t, counter, 2) assert.NilError(t, err) assert.Equal(t, res, `{"pgo_versions":[{"tag":"v5.0.4"},{"tag":"v5.0.3"},{"tag":"v5.0.2"},{"tag":"v5.0.1"},{"tag":"v5.0.0"}]}`) @@ -158,9 +156,6 @@ func TestCheckForUpgrades(t *testing.T) { // TODO(benjaminjb): Replace `fake` with envtest func TestCheckForUpgradesScheduler(t *testing.T) { fakeClient := setupFakeClientWithPGOScheme(t, false) - _, server := setupVersionServer(t, true) - defer server.Close() - cfg := &rest.Config{Host: server.URL} t.Run("panic from checkForUpgrades doesn't bubble up", func(t *testing.T) { ctx := context.Background() @@ -175,12 +170,11 @@ func TestCheckForUpgradesScheduler(t *testing.T) { // A panicking call funcFoo = func() (*http.Response, error) { - panic(fmt.Errorf("oh no!")) + panic("oh no!") } s := CheckForUpgradesScheduler{ Client: fakeClient, - Config: cfg, } s.check(ctx) @@ -213,7 +207,6 @@ func TestCheckForUpgradesScheduler(t *testing.T) { defer cancel() s := CheckForUpgradesScheduler{ Client: fakeClient, - Config: cfg, Refresh: 1 * time.Second, } assert.ErrorIs(t, context.DeadlineExceeded, s.Start(ctx)) diff --git a/internal/util/secrets.go b/internal/util/secrets.go index 82768c9386..0d372aea3c 100644 --- a/internal/util/secrets.go +++ b/internal/util/secrets.go @@ -1,4 +1,4 @@ -// Copyright 2017 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/internal/util/secrets_test.go b/internal/util/secrets_test.go index 5d549ca89e..ae5f7f5b05 100644 --- a/internal/util/secrets_test.go +++ b/internal/util/secrets_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -55,7 +55,7 @@ func TestGenerateAlphaNumericPassword(t *testing.T) { } previous := sets.Set[string]{} - for i := 0; i < 10; i++ { + for range 10 { password, err := GenerateAlphaNumericPassword(5) assert.NilError(t, err) @@ -80,7 +80,7 @@ func TestGenerateASCIIPassword(t *testing.T) { } previous := sets.Set[string]{} - for i := 0; i < 10; i++ { + for range 10 { password, err := GenerateASCIIPassword(5) assert.NilError(t, err) diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 72634ebbc6..0000000000 --- a/internal/util/util.go +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2017 - 2024 Crunchy Data Solutions, Inc. -// -// SPDX-License-Identifier: Apache-2.0 - -package util - -import ( - "strings" -) - -// SQLQuoteIdentifier quotes an "identifier" (e.g. a table or a column name) to -// be used as part of an SQL statement. -// -// Any double quotes in name will be escaped. The quoted identifier will be -// case-sensitive when used in a query. If the input string contains a zero -// byte, the result will be truncated immediately before it. -// -// Implementation borrowed from lib/pq: https://github.com/lib/pq which is -// licensed under the MIT License -func SQLQuoteIdentifier(identifier string) string { - end := strings.IndexRune(identifier, 0) - - if end > -1 { - identifier = identifier[:end] - } - - return `"` + strings.Replace(identifier, `"`, `""`, -1) + `"` -} - -// SQLQuoteLiteral quotes a 'literal' (e.g. a parameter, often used to pass literal -// to DDL and other statements that do not accept parameters) to be used as part -// of an SQL statement. -// -// Any single quotes in name will be escaped. Any backslashes (i.e. "\") will be -// replaced by two backslashes (i.e. "\\") and the C-style escape identifier -// that PostgreSQL provides ('E') will be prepended to the string. -// -// Implementation borrowed from lib/pq: https://github.com/lib/pq which is -// licensed under the MIT License. Curiously, @jkatz and @cbandy were the ones -// who worked on the patch to add this, prior to being at Crunchy Data -func SQLQuoteLiteral(literal string) string { - // This follows the PostgreSQL internal algorithm for handling quoted literals - // from libpq, which can be found in the "PQEscapeStringInternal" function, - // which is found in the libpq/fe-exec.c source file: - // https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/interfaces/libpq/fe-exec.c - // - // substitute any single-quotes (') with two single-quotes ('') - literal = strings.Replace(literal, `'`, `''`, -1) - // determine if the string has any backslashes (\) in it. - // if it does, replace any backslashes (\) with two backslashes (\\) - // then, we need to wrap the entire string with a PostgreSQL - // C-style escape. Per how "PQEscapeStringInternal" handles this case, we - // also add a space before the "E" - if strings.Contains(literal, `\`) { - literal = strings.Replace(literal, `\`, `\\`, -1) - literal = ` E'` + literal + `'` - } else { - // otherwise, we can just wrap the literal with a pair of single quotes - literal = `'` + literal + `'` - } - return literal -} diff --git a/internal/util/volumes.go b/internal/util/volumes.go new file mode 100644 index 0000000000..34e2699b54 --- /dev/null +++ b/internal/util/volumes.go @@ -0,0 +1,42 @@ +// Copyright 2017 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" +) + +// AddVolumeAndMountsToPod takes a Pod spec and a PVC and adds a Volume to the Pod spec with +// the PVC as the VolumeSource and mounts the volume to all containers and init containers +// in the Pod spec. +func AddVolumeAndMountsToPod(podSpec *corev1.PodSpec, volume *corev1.PersistentVolumeClaim) { + + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: volume.Name, + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: volume.Name, + }, + }, + }) + + for i := range podSpec.Containers { + podSpec.Containers[i].VolumeMounts = append(podSpec.Containers[i].VolumeMounts, + corev1.VolumeMount{ + Name: volume.Name, + MountPath: fmt.Sprintf("/volumes/%s", volume.Name), + }) + } + + for i := range podSpec.InitContainers { + podSpec.InitContainers[i].VolumeMounts = append(podSpec.InitContainers[i].VolumeMounts, + corev1.VolumeMount{ + Name: volume.Name, + MountPath: fmt.Sprintf("/volumes/%s", volume.Name), + }) + } +} diff --git a/internal/util/volumes_test.go b/internal/util/volumes_test.go new file mode 100644 index 0000000000..b438943e3a --- /dev/null +++ b/internal/util/volumes_test.go @@ -0,0 +1,78 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package util + +import ( + "testing" + + "github.com/google/go-cmp/cmp/cmpopts" + "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crunchydata/postgres-operator/internal/testing/cmp" +) + +func TestAddVolumeAndMountsToPod(t *testing.T) { + pod := &corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "database"}, + {Name: "other"}, + {Name: "pgbackrest"}, + }, + InitContainers: []corev1.Container{ + {Name: "initializer"}, + {Name: "another"}, + }, + } + + volume := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "volume-name", + }, + } + + alwaysExpect := func(t testing.TB, result *corev1.PodSpec) { + // Only Containers, InitContainers, and Volumes fields have changed. + assert.DeepEqual(t, *pod, *result, cmpopts.IgnoreFields(*pod, "Containers", "InitContainers", "Volumes")) + + // Volume is mounted to all containers + assert.Assert(t, cmp.MarshalMatches(result.Containers, ` +- name: database + resources: {} + volumeMounts: + - mountPath: /volumes/volume-name + name: volume-name +- name: other + resources: {} + volumeMounts: + - mountPath: /volumes/volume-name + name: volume-name +- name: pgbackrest + resources: {} + volumeMounts: + - mountPath: /volumes/volume-name + name: volume-name + `)) + + // Volume is mounted to all init containers + assert.Assert(t, cmp.MarshalMatches(result.InitContainers, ` +- name: initializer + resources: {} + volumeMounts: + - mountPath: /volumes/volume-name + name: volume-name +- name: another + resources: {} + volumeMounts: + - mountPath: /volumes/volume-name + name: volume-name + `)) + } + + out := pod.DeepCopy() + AddVolumeAndMountsToPod(out, volume) + alwaysExpect(t, out) +} diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1/README.md b/pkg/apis/postgres-operator.crunchydata.com/v1/README.md new file mode 100644 index 0000000000..f6923ff413 --- /dev/null +++ b/pkg/apis/postgres-operator.crunchydata.com/v1/README.md @@ -0,0 +1,73 @@ +# Multiversion CRD + +The purpose of this README is to discuss the current/future experience of transitioning between +versions of the postgrescluster CRD, as well as to identify future work. + +## Version sorting and how that affects retrieval + +[Version sorting in Kubernetes](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#version-priority) means that v1 takes +precedence over v1beta1. Version sorting disregards storage version. + +So if you run `kubectl explain postgrescluster.spec.userInterface` you will get the v1 explainer. +In order to get the explainer in a particular version form, you need to add the `--api-version` flag: + +```bash +kubectl explain postgrescluster.spec.userInterface --api-version=postgres-operator.crunchydata.com/v1 +``` + +For `kubectl get`, the way to specify api version is in the resource name. That is, rather than +`kubectl get postgrescluster`, you could put + +```bash +kubectl get postgrescluster.v1beta1.postgres-operator.crunchydata.com hippo -o yaml +``` + +That will return the `v1beta1` representation of that cluster. + +**Future work**: The CLI tool calls kubectl, so we may need to expose/add a flag to allow people to specify +versions; we may also need to update some of the create and other commands to allow multiple versions (if desired). + +### K9s and other GUIs + +I'm not sure what other tools people use, but I know k9s is pretty popular. Unfortunately, +I cannot find a way to specify the form a K8s object is retrieved in. See [here](https://github.com/derailed/k9s/issues/838). + +## Transitioning from v1beta1 to v1 + +If you have a v1beta1 cluster and want to save it as v1, you can change the `apiVersion` field: + +Change + +```yaml +apiVersion: postgres-operator.crunchydata.com/v1beta1 +``` + +to + +```yaml +apiVersion: postgres-operator.crunchydata.com/v1 +``` + +And if the cluster is acceptable as a v1 object, it will be saved. + +It may return a warning if some new XValidation rule is being tested. For instance, since we've added a rule +that the `spec.userInterface` field should be null in v1, if you have a postgrescluster with that field +in a v1beta1 but _do not_ change that field, then you can save your cluster as a v1 version even though it will +return a warning that that field should be null. + +(This is a result of using validation ratcheting, which should be enabled in K8s 1.30+ / OCP 4.17+.) + +If you want to test whether a save or adjustment will be successful, you can run a dry-run first. That is, +add `--dry-run=server` to your create/apply command. This will check against the object as it currently exists +for the server. + +If you get blocked or if you get a warning and want to eliminate that warning, the way to do that is to update +the spec or make changes that will enable that spec to be valid. Hopefully the error messages from the K8s +API will help determine the change that are required. + +That is, if you have a `spec.userInterface`, and the error informs you that this field is no longer available in v1, +you may need to check our documentation on the preferred way to deploy a pgAdmin4 deployment. + +(We may in the future want to actually provide steps for all of the fields that we are changing, +e.g., a migration guide.) + diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1/groupversion_info.go b/pkg/apis/postgres-operator.crunchydata.com/v1/groupversion_info.go new file mode 100644 index 0000000000..d504cd2a8d --- /dev/null +++ b/pkg/apis/postgres-operator.crunchydata.com/v1/groupversion_info.go @@ -0,0 +1,24 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +// package v1 contains API Schema definitions for the postgres-operator v1beta1 API group +// +kubebuilder:object:generate=true +// +groupName=postgres-operator.crunchydata.com +package v1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "postgres-operator.crunchydata.com", Version: "v1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1/postgrescluster_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1/postgrescluster_types.go new file mode 100644 index 0000000000..e9778b93bb --- /dev/null +++ b/pkg/apis/postgres-operator.crunchydata.com/v1/postgrescluster_types.go @@ -0,0 +1,741 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +// PostgresClusterSpec defines the desired state of PostgresCluster +type PostgresClusterSpec struct { + // +optional + Metadata *v1beta1.Metadata `json:"metadata,omitempty"` + + // Specifies a data source for bootstrapping the PostgreSQL cluster. + // +optional + DataSource *DataSource `json:"dataSource,omitempty"` + + // Authentication settings for the PostgreSQL server + // +optional + Authentication *v1beta1.PostgresAuthenticationSpec `json:"authentication,omitempty"` + + // PostgreSQL backup configuration + // +optional + Backups Backups `json:"backups,omitzero"` + + // General configuration of the PostgreSQL server + // +optional + Config *v1beta1.PostgresConfigSpec `json:"config,omitempty"` + + // The secret containing the Certificates and Keys to encrypt PostgreSQL + // traffic will need to contain the server TLS certificate, TLS key and the + // Certificate Authority certificate with the data keys set to tls.crt, + // tls.key and ca.crt, respectively. It will then be mounted as a volume + // projection to the '/pgconf/tls' directory. For more information on + // Kubernetes secret projections, please see + // https://k8s.io/docs/concepts/configuration/secret/#projection-of-secret-keys-to-specific-paths + // NOTE: If CustomTLSSecret is provided, CustomReplicationClientTLSSecret + // MUST be provided and the ca.crt provided must be the same. + // +optional + CustomTLSSecret *corev1.SecretProjection `json:"customTLSSecret,omitempty"` + + // The secret containing the replication client certificates and keys for + // secure connections to the PostgreSQL server. It will need to contain the + // client TLS certificate, TLS key and the Certificate Authority certificate + // with the data keys set to tls.crt, tls.key and ca.crt, respectively. + // NOTE: If CustomReplicationClientTLSSecret is provided, CustomTLSSecret + // MUST be provided and the ca.crt provided must be the same. + // +optional + CustomReplicationClientTLSSecret *corev1.SecretProjection `json:"customReplicationTLSSecret,omitempty"` + + // DatabaseInitSQL defines a ConfigMap containing custom SQL that will + // be run after the cluster is initialized. This ConfigMap must be in the same + // namespace as the cluster. + // +optional + DatabaseInitSQL *DatabaseInitSQL `json:"databaseInitSQL,omitempty"` + // Whether or not the PostgreSQL cluster should use the defined default + // scheduling constraints. If the field is unset or false, the default + // scheduling constraints will be used in addition to any custom constraints + // provided. + // +optional + DisableDefaultPodScheduling *bool `json:"disableDefaultPodScheduling,omitempty"` + + // The image name to use for PostgreSQL containers. When omitted, the value + // comes from an operator environment variable. For standard PostgreSQL images, + // the format is RELATED_IMAGE_POSTGRES_{postgresVersion}, + // e.g. RELATED_IMAGE_POSTGRES_13. For PostGIS enabled PostgreSQL images, + // the format is RELATED_IMAGE_POSTGRES_{postgresVersion}_GIS_{postGISVersion}, + // e.g. RELATED_IMAGE_POSTGRES_13_GIS_3.1. + // +optional + // +operator-sdk:csv:customresourcedefinitions:type=spec,order=1 + Image string `json:"image,omitempty"` + + // ImagePullPolicy is used to determine when Kubernetes will attempt to + // pull (download) container images. + // More info: https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 + // +kubebuilder:validation:Type=string + // + // +kubebuilder:validation:Enum={Always,Never,IfNotPresent} + // +optional + ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` + + // The image pull secrets used to pull from a private registry + // Changing this value causes all running pods to restart. + // https://k8s.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + // +optional + ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + + // Specifies one or more sets of PostgreSQL pods that replicate data for + // this cluster. + // +listType=map + // +listMapKey=name + // +kubebuilder:validation:MinItems=1 + // +operator-sdk:csv:customresourcedefinitions:type=spec,order=2 + InstanceSets []PostgresInstanceSetSpec `json:"instances"` + + // Configuration for the OpenTelemetry collector container used to collect + // logs and metrics. + // +optional + Instrumentation *v1beta1.InstrumentationSpec `json:"instrumentation,omitempty"` + + // Whether or not the PostgreSQL cluster is being deployed to an OpenShift + // environment. If the field is unset, the operator will automatically + // detect the environment. + // +optional + OpenShift *bool `json:"openshift,omitempty"` + + // +optional + Patroni *v1beta1.PatroniSpec `json:"patroni,omitempty"` + + // Suspends the rollout and reconciliation of changes made to the + // PostgresCluster spec. + // +optional + Paused *bool `json:"paused,omitempty"` + + // The port on which PostgreSQL should listen. + // +optional + // +kubebuilder:default=5432 + // +kubebuilder:validation:Minimum=1024 + Port *int32 `json:"port,omitempty"` + + // The major version of PostgreSQL installed in the PostgreSQL image + // +kubebuilder:validation:Required + // +kubebuilder:validation:Minimum=11 + // +kubebuilder:validation:Maximum=17 + // +operator-sdk:csv:customresourcedefinitions:type=spec,order=1 + PostgresVersion int `json:"postgresVersion"` + + // The PostGIS extension version installed in the PostgreSQL image. + // When image is not set, indicates a PostGIS enabled image will be used. + // +optional + PostGISVersion string `json:"postGISVersion,omitempty"` + + // The specification of a proxy that connects to PostgreSQL. + // +optional + Proxy *PostgresProxySpec `json:"proxy,omitempty"` + + // The specification of a user interface that connects to PostgreSQL. -- DEPRECATED + // +optional + // +kubebuilder:validation:XValidation:rule="type(self) == null_type", message="userInterface not available in v1" + UserInterface *UserInterfaceSpec `json:"userInterface,omitempty"` + + // The specification of monitoring tools that connect to PostgreSQL + // +optional + Monitoring *MonitoringSpec `json:"monitoring,omitempty"` + + // Specification of the service that exposes the PostgreSQL primary instance. + // +optional + Service *v1beta1.ServiceSpec `json:"service,omitempty"` + + // Specification of the service that exposes PostgreSQL replica instances + // +optional + ReplicaService *v1beta1.ServiceSpec `json:"replicaService,omitempty"` + + // Whether or not the PostgreSQL cluster should be stopped. + // When this is true, workloads are scaled to zero and CronJobs + // are suspended. + // Other resources, such as Services and Volumes, remain in place. + // +optional + Shutdown *bool `json:"shutdown,omitempty"` + + // Run this cluster as a read-only copy of an existing cluster or archive. + // +optional + Standby *PostgresStandbySpec `json:"standby,omitempty"` + + // A list of group IDs applied to the process of a container. These can be + // useful when accessing shared file systems with constrained permissions. + // More info: https://kubernetes.io/docs/reference/kubernetes-api/workload-resources/pod-v1/#security-context + // --- + // +kubebuilder:validation:Optional + // + // Containers should not run with a root GID. + // - https://kubernetes.io/docs/concepts/security/pod-security-standards/ + // +kubebuilder:validation:items:Minimum=1 + // + // Supplementary GIDs must fit within int32. + // - https://releases.k8s.io/v1.18.0/pkg/apis/core/validation/validation.go#L3659-L3663 + // - https://releases.k8s.io/v1.22.0/pkg/apis/core/validation/validation.go#L3923-L3927 + // +kubebuilder:validation:items:Maximum=2147483647 + SupplementalGroups []int64 `json:"supplementalGroups,omitempty"` + + // Users to create inside PostgreSQL and the databases they should access. + // The default creates one user that can access one database matching the + // PostgresCluster name. An empty list creates no users. Removing a user + // from this list does NOT drop the user nor revoke their access. + // +listType=map + // +listMapKey=name + // +kubebuilder:validation:MaxItems=64 + // +optional + Users []v1beta1.PostgresUserSpec `json:"users,omitempty"` +} + +// DataSource defines data sources for a new PostgresCluster. +type DataSource struct { + // Defines a pgBackRest cloud-based data source that can be used to pre-populate the + // PostgreSQL data directory for a new PostgreSQL cluster using a pgBackRest restore. + // The PGBackRest field is incompatible with the PostgresCluster field: only one + // data source can be used for pre-populating a new PostgreSQL cluster + // +optional + // +kubebuilder:validation:XValidation:rule="!has(self.repo.volume)", message="Only S3, GCS or Azure repos can be used as a pgBackRest data source.", fieldPath=".repo" + PGBackRest *v1beta1.PGBackRestDataSource `json:"pgbackrest,omitempty"` + + // Defines a pgBackRest data source that can be used to pre-populate the PostgreSQL data + // directory for a new PostgreSQL cluster using a pgBackRest restore. + // The PGBackRest field is incompatible with the PostgresCluster field: only one + // data source can be used for pre-populating a new PostgreSQL cluster + // +optional + PostgresCluster *PostgresClusterDataSource `json:"postgresCluster,omitempty"` + + // Defines any existing volumes to reuse for this PostgresCluster. + // +optional + Volumes *DataSourceVolumes `json:"volumes,omitempty"` +} + +// DataSourceVolumes defines any existing volumes to reuse for this PostgresCluster. +type DataSourceVolumes struct { + // Defines the existing pgData volume and directory to use in the current + // PostgresCluster. + // +optional + PGDataVolume *DataSourceVolume `json:"pgDataVolume,omitempty"` + + // Defines the existing pg_wal volume and directory to use in the current + // PostgresCluster. Note that a defined pg_wal volume MUST be accompanied by + // a pgData volume. + // +optional + PGWALVolume *DataSourceVolume `json:"pgWALVolume,omitempty"` + + // Defines the existing pgBackRest repo volume and directory to use in the + // current PostgresCluster. + // +optional + PGBackRestVolume *DataSourceVolume `json:"pgBackRestVolume,omitempty"` +} + +// DataSourceVolume defines the PVC name and data directory path for an existing cluster volume. +type DataSourceVolume struct { + // The existing PVC name. + PVCName string `json:"pvcName"` + + // The existing directory. When not set, a move Job is not created for the + // associated volume. + // +optional + Directory string `json:"directory,omitempty"` +} + +// DatabaseInitSQL defines a ConfigMap containing custom SQL that will +// be run after the cluster is initialized. This ConfigMap must be in the same +// namespace as the cluster. +type DatabaseInitSQL struct { + // Name is the name of a ConfigMap + // +required + Name string `json:"name"` + + // Key is the ConfigMap data key that points to a SQL string + // +required + Key string `json:"key"` +} + +// PostgresClusterDataSource defines a data source for bootstrapping PostgreSQL clusters using a +// an existing PostgresCluster. +type PostgresClusterDataSource struct { + + // The name of an existing PostgresCluster to use as the data source for the new PostgresCluster. + // Defaults to the name of the PostgresCluster being created if not provided. + // +optional + ClusterName string `json:"clusterName,omitempty"` + + // The namespace of the cluster specified as the data source using the clusterName field. + // Defaults to the namespace of the PostgresCluster being created if not provided. + // +optional + ClusterNamespace string `json:"clusterNamespace,omitempty"` + + // The name of the pgBackRest repo within the source PostgresCluster that contains the backups + // that should be utilized to perform a pgBackRest restore when initializing the data source + // for the new PostgresCluster. + // +kubebuilder:validation:Required + // +kubebuilder:validation:Pattern=^repo[1-4] + RepoName string `json:"repoName"` + + // Command line options to include when running the pgBackRest restore command. + // https://pgbackrest.org/command.html#command-restore + // +optional + Options []string `json:"options,omitempty"` + + // Resource requirements for the pgBackRest restore Job. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitzero"` + + // Scheduling constraints of the pgBackRest restore Job. + // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + // +optional + Affinity *corev1.Affinity `json:"affinity,omitempty"` + + // Priority class name for the pgBackRest restore Job pod. Changing this + // value causes PostgreSQL to restart. + // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + // +optional + PriorityClassName *string `json:"priorityClassName,omitempty"` + + // Tolerations of the pgBackRest restore Job. + // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // Volumes to add to Restore Job Pods + // +optional + Volumes *v1beta1.PGBackRestVolumesSpec `json:"volumes,omitempty"` +} + +// Default defines several key default values for a Postgres cluster. +func (s *PostgresClusterSpec) Default() { + for i := range s.InstanceSets { + s.InstanceSets[i].Default(i) + } + + if s.Patroni == nil { + s.Patroni = new(v1beta1.PatroniSpec) + } + s.Patroni.Default() + + if s.Port == nil { + s.Port = new(int32) + *s.Port = 5432 + } + + if s.Proxy != nil { + s.Proxy.Default() + } + + if s.UserInterface != nil { + s.UserInterface.Default() + } +} + +// Backups defines a PostgreSQL archive configuration +type Backups struct { + + // pgBackRest archive configuration + // +optional + PGBackRest v1beta1.PGBackRestArchive `json:"pgbackrest"` + + // VolumeSnapshot configuration + // +optional + Snapshots *VolumeSnapshots `json:"snapshots,omitempty"` +} + +// PostgresClusterStatus defines the observed state of PostgresCluster +type PostgresClusterStatus struct { + + // Identifies the databases that have been installed into PostgreSQL. + DatabaseRevision string `json:"databaseRevision,omitempty"` + + // Current state of PostgreSQL instances. + // +listType=map + // +listMapKey=name + // +optional + InstanceSets []PostgresInstanceSetStatus `json:"instances,omitempty"` + + // +optional + Patroni v1beta1.PatroniStatus `json:"patroni,omitzero"` + + // Status information for pgBackRest + // +optional + PGBackRest *v1beta1.PGBackRestStatus `json:"pgbackrest,omitempty"` + + // +optional + RegistrationRequired *RegistrationRequirementStatus `json:"registrationRequired,omitempty"` + + // +optional + TokenRequired string `json:"tokenRequired,omitempty"` + + // Stores the current PostgreSQL major version following a successful + // major PostgreSQL upgrade. + // +optional + PostgresVersion int `json:"postgresVersion"` + + // Current state of the PostgreSQL proxy. + // +optional + Proxy PostgresProxyStatus `json:"proxy,omitzero"` + + // The instance that should be started first when bootstrapping and/or starting a + // PostgresCluster. + // +optional + StartupInstance string `json:"startupInstance,omitempty"` + + // The instance set associated with the startupInstance + // +optional + StartupInstanceSet string `json:"startupInstanceSet,omitempty"` + + // Current state of the PostgreSQL user interface. + // +optional + UserInterface *PostgresUserInterfaceStatus `json:"userInterface,omitempty"` + + // Identifies the users that have been installed into PostgreSQL. + UsersRevision string `json:"usersRevision,omitempty"` + + // Current state of PostgreSQL cluster monitoring tool configuration + // +optional + Monitoring MonitoringStatus `json:"monitoring,omitzero"` + + // DatabaseInitSQL state of custom database initialization in the cluster + // +optional + DatabaseInitSQL *string `json:"databaseInitSQL,omitempty"` + + // observedGeneration represents the .metadata.generation on which the status was based. + // +optional + // +kubebuilder:validation:Minimum=0 + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // conditions represent the observations of postgrescluster's current state. + // Known .status.conditions.type are: "PersistentVolumeResizing", + // "Progressing", "ProxyAvailable" + // +optional + // +listType=map + // +listMapKey=type + // +operator-sdk:csv:customresourcedefinitions:type=status,xDescriptors={"urn:alm:descriptor:io.kubernetes.conditions"} + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// PostgresClusterStatus condition types. +const ( + PersistentVolumeResizing = "PersistentVolumeResizing" + PersistentVolumeResizeError = "PersistentVolumeResizeError" + PostgresClusterProgressing = "Progressing" + ProxyAvailable = "ProxyAvailable" + Registered = "Registered" +) + +type PostgresInstanceSetSpec struct { + // +optional + Metadata *v1beta1.Metadata `json:"metadata,omitempty"` + + // This value goes into the name of an appsv1.StatefulSet, the hostname of + // a corev1.Pod, and label values. The pattern below is IsDNS1123Label + // wrapped in "()?" to accommodate the empty default. + // + // The Pods created by a StatefulSet have a "controller-revision-hash" label + // comprised of the StatefulSet name, a dash, and a 10-character hash. + // The length below is derived from limitations on label values: + // + // 63 (max) ≥ len(cluster) + 1 (dash) + // + len(set) + 1 (dash) + 4 (id) + // + 1 (dash) + 10 (hash) + // + // See: https://issue.k8s.io/64023 + + // Name that associates this set of PostgreSQL pods. This field is optional + // when only one instance set is defined. Each instance set in a cluster + // must have a unique name. The combined length of this and the cluster name + // must be 46 characters or less. + // +optional + // +kubebuilder:default="" + // +kubebuilder:validation:Pattern=`^([a-z0-9]([-a-z0-9]*[a-z0-9])?)?$` + Name string `json:"name"` + + // Scheduling constraints of a PostgreSQL pod. Changing this value causes + // PostgreSQL to restart. + // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node + // +optional + Affinity *corev1.Affinity `json:"affinity,omitempty"` + + // Custom sidecars for PostgreSQL instance pods. Changing this value causes + // PostgreSQL to restart. + // +optional + Containers []corev1.Container `json:"containers,omitempty"` + + // Defines a PersistentVolumeClaim for PostgreSQL data. + // More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes + // --- + // +required + DataVolumeClaimSpec v1beta1.VolumeClaimSpec `json:"dataVolumeClaimSpec"` + + // Priority class name for the PostgreSQL pod. Changing this value causes + // PostgreSQL to restart. + // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + // +optional + PriorityClassName *string `json:"priorityClassName,omitempty"` + + // Number of desired PostgreSQL pods. + // +optional + // +kubebuilder:default=1 + // +kubebuilder:validation:Minimum=1 + Replicas *int32 `json:"replicas,omitempty"` + + // Minimum number of pods that should be available at a time. + // Defaults to one when the replicas field is greater than one. + // +optional + MinAvailable *intstr.IntOrString `json:"minAvailable,omitempty"` + + // Compute resources of a PostgreSQL container. + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitzero"` + + // Configuration for instance sidecar containers + // +optional + Sidecars *InstanceSidecars `json:"sidecars,omitempty"` + + // Tolerations of a PostgreSQL pod. Changing this value causes PostgreSQL to restart. + // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration + // +optional + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // Topology spread constraints of a PostgreSQL pod. Changing this value causes + // PostgreSQL to restart. + // More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/ + // +optional + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + + // Defines a separate PersistentVolumeClaim for PostgreSQL's write-ahead log. + // More info: https://www.postgresql.org/docs/current/wal.html + // --- + // +optional + WALVolumeClaimSpec *v1beta1.VolumeClaimSpec `json:"walVolumeClaimSpec,omitempty"` + + // The list of tablespaces volumes to mount for this postgrescluster + // This field requires enabling TablespaceVolumes feature gate + // +listType=map + // +listMapKey=name + // +optional + TablespaceVolumes []TablespaceVolume `json:"tablespaceVolumes,omitempty"` + + Volumes *v1beta1.PostgresVolumesSpec `json:"volumes,omitempty"` +} + +type TablespaceVolume struct { + // This value goes into + // a. the name of a corev1.PersistentVolumeClaim, + // b. a label value, and + // c. a path name. + // So it must match both IsDNS1123Subdomain and IsValidLabelValue; + // and be valid as a file path. + + // The name for the tablespace, used as the path name for the volume. + // Must be unique in the instance set since they become the directory names. + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Pattern=`^[a-z][a-z0-9]*$` + // +kubebuilder:validation:Type=string + Name string `json:"name"` + + // Defines a PersistentVolumeClaim for a tablespace. + // More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes + // --- + // +required + DataVolumeClaimSpec v1beta1.VolumeClaimSpec `json:"dataVolumeClaimSpec"` +} + +// InstanceSidecars defines the configuration for instance sidecar containers +type InstanceSidecars struct { + // Defines the configuration for the replica cert copy sidecar container + // +optional + ReplicaCertCopy *v1beta1.Sidecar `json:"replicaCertCopy,omitempty"` +} + +// Default sets the default values for an instance set spec, including the name +// suffix and number of replicas. +func (s *PostgresInstanceSetSpec) Default(i int) { + if s.Name == "" { + s.Name = fmt.Sprintf("%02d", i) + } + if s.Replicas == nil { + s.Replicas = new(int32) + *s.Replicas = 1 + } +} + +type PostgresInstanceSetStatus struct { + Name string `json:"name"` + + // Total number of ready pods. + // +optional + ReadyReplicas int32 `json:"readyReplicas,omitempty"` + + // Total number of pods. + // +optional + Replicas int32 `json:"replicas,omitempty"` + + // Total number of pods that have the desired specification. + // +optional + UpdatedReplicas int32 `json:"updatedReplicas,omitempty"` + + // Desired Size of the pgData volume + // +optional + DesiredPGDataVolume map[string]string `json:"desiredPGDataVolume,omitempty"` +} + +// PostgresProxySpec is a union of the supported PostgreSQL proxies. +type PostgresProxySpec struct { + + // Defines a PgBouncer proxy and connection pooler. + PGBouncer *v1beta1.PGBouncerPodSpec `json:"pgBouncer"` +} + +// Default sets the defaults for any proxies that are set. +func (s *PostgresProxySpec) Default() { + if s.PGBouncer != nil { + s.PGBouncer.Default() + } +} + +type RegistrationRequirementStatus struct { + PGOVersion string `json:"pgoVersion,omitempty"` +} + +type PostgresProxyStatus struct { + // +optional + PGBouncer v1beta1.PGBouncerPodStatus `json:"pgBouncer,omitzero"` +} + +// PostgresStandbySpec defines if/how the cluster should be a hot standby. +type PostgresStandbySpec struct { + // Whether or not the PostgreSQL cluster should be read-only. When this is + // true, WAL files are applied from a pgBackRest repository or another + // PostgreSQL server. + // +optional + // +kubebuilder:default=true + Enabled bool `json:"enabled"` + + // The name of the pgBackRest repository to follow for WAL files. + // +optional + // +kubebuilder:validation:Pattern=^repo[1-4] + RepoName string `json:"repoName,omitempty"` + + // Network address of the PostgreSQL server to follow via streaming replication. + // +optional + Host string `json:"host,omitempty"` + + // Network port of the PostgreSQL server to follow via streaming replication. + // +optional + // +kubebuilder:validation:Minimum=1024 + Port *int32 `json:"port,omitempty"` +} + +// UserInterfaceSpec is a union of the supported PostgreSQL user interfaces. +type UserInterfaceSpec struct { + + // Defines a pgAdmin user interface. + PGAdmin *v1beta1.PGAdminPodSpec `json:"pgAdmin"` +} + +// Default sets the defaults for any user interfaces that are set. +func (s *UserInterfaceSpec) Default() { + if s.PGAdmin != nil { + s.PGAdmin.Default() + } +} + +// PostgresUserInterfaceStatus is a union of the supported PostgreSQL user +// interface statuses. +type PostgresUserInterfaceStatus struct { + + // The state of the pgAdmin user interface. + // +optional + PGAdmin v1beta1.PGAdminPodStatus `json:"pgAdmin,omitzero"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +operator-sdk:csv:customresourcedefinitions:resources={{ConfigMap,v1},{Secret,v1},{Service,v1},{CronJob,v1beta1},{Deployment,v1},{Job,v1},{StatefulSet,v1},{PersistentVolumeClaim,v1}} + +// PostgresCluster is the Schema for the postgresclusters API +type PostgresCluster struct { + // ObjectMeta.Name is a DNS subdomain. + // - https://docs.k8s.io/concepts/overview/working-with-objects/names/#dns-subdomain-names + // - https://releases.k8s.io/v1.21.0/staging/src/k8s.io/apiextensions-apiserver/pkg/registry/customresource/validator.go#L60 + + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // NOTE(cbandy): Every PostgresCluster needs a Spec, but it is optional here + // so ObjectMeta can be managed independently. + + // +optional + Spec PostgresClusterSpec `json:"spec,omitzero"` + // +optional + Status PostgresClusterStatus `json:"status,omitzero"` +} + +// Default implements "sigs.k8s.io/controller-runtime/pkg/webhook.Defaulter" so +// a webhook can be registered for the type. +// - https://book.kubebuilder.io/reference/webhook-overview.html +func (c *PostgresCluster) Default() { + if len(c.APIVersion) == 0 { + c.APIVersion = GroupVersion.String() + } + if len(c.Kind) == 0 { + c.Kind = "PostgresCluster" + } + c.Spec.Default() +} + +// +kubebuilder:object:root=true + +// PostgresClusterList contains a list of PostgresCluster +type PostgresClusterList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []PostgresCluster `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PostgresCluster{}, &PostgresClusterList{}) +} + +// MonitoringSpec is a union of the supported PostgreSQL Monitoring tools +type MonitoringSpec struct { + // +optional + PGMonitor *v1beta1.PGMonitorSpec `json:"pgmonitor,omitempty"` +} + +// MonitoringStatus is the current state of PostgreSQL cluster monitoring tool +// configuration +type MonitoringStatus struct { + // +optional + ExporterConfiguration string `json:"exporterConfiguration,omitempty"` +} + +func NewPostgresCluster() *PostgresCluster { + cluster := &PostgresCluster{} + cluster.SetGroupVersionKind(GroupVersion.WithKind("PostgresCluster")) + return cluster +} + +// VolumeSnapshots defines the configuration for VolumeSnapshots +type VolumeSnapshots struct { + // Name of the VolumeSnapshotClass that should be used by VolumeSnapshots + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + VolumeSnapshotClassName string `json:"volumeSnapshotClassName"` +} diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1/zz_generated.deepcopy.go b/pkg/apis/postgres-operator.crunchydata.com/v1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..b0916d8cce --- /dev/null +++ b/pkg/apis/postgres-operator.crunchydata.com/v1/zz_generated.deepcopy.go @@ -0,0 +1,721 @@ +//go:build !ignore_autogenerated + +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by controller-gen. DO NOT EDIT. + +package v1 + +import ( + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Backups) DeepCopyInto(out *Backups) { + *out = *in + in.PGBackRest.DeepCopyInto(&out.PGBackRest) + if in.Snapshots != nil { + in, out := &in.Snapshots, &out.Snapshots + *out = new(VolumeSnapshots) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Backups. +func (in *Backups) DeepCopy() *Backups { + if in == nil { + return nil + } + out := new(Backups) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSource) DeepCopyInto(out *DataSource) { + *out = *in + if in.PGBackRest != nil { + in, out := &in.PGBackRest, &out.PGBackRest + *out = new(v1beta1.PGBackRestDataSource) + (*in).DeepCopyInto(*out) + } + if in.PostgresCluster != nil { + in, out := &in.PostgresCluster, &out.PostgresCluster + *out = new(PostgresClusterDataSource) + (*in).DeepCopyInto(*out) + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(DataSourceVolumes) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSource. +func (in *DataSource) DeepCopy() *DataSource { + if in == nil { + return nil + } + out := new(DataSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSourceVolume) DeepCopyInto(out *DataSourceVolume) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceVolume. +func (in *DataSourceVolume) DeepCopy() *DataSourceVolume { + if in == nil { + return nil + } + out := new(DataSourceVolume) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DataSourceVolumes) DeepCopyInto(out *DataSourceVolumes) { + *out = *in + if in.PGDataVolume != nil { + in, out := &in.PGDataVolume, &out.PGDataVolume + *out = new(DataSourceVolume) + **out = **in + } + if in.PGWALVolume != nil { + in, out := &in.PGWALVolume, &out.PGWALVolume + *out = new(DataSourceVolume) + **out = **in + } + if in.PGBackRestVolume != nil { + in, out := &in.PGBackRestVolume, &out.PGBackRestVolume + *out = new(DataSourceVolume) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataSourceVolumes. +func (in *DataSourceVolumes) DeepCopy() *DataSourceVolumes { + if in == nil { + return nil + } + out := new(DataSourceVolumes) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DatabaseInitSQL) DeepCopyInto(out *DatabaseInitSQL) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseInitSQL. +func (in *DatabaseInitSQL) DeepCopy() *DatabaseInitSQL { + if in == nil { + return nil + } + out := new(DatabaseInitSQL) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstanceSidecars) DeepCopyInto(out *InstanceSidecars) { + *out = *in + if in.ReplicaCertCopy != nil { + in, out := &in.ReplicaCertCopy, &out.ReplicaCertCopy + *out = new(v1beta1.Sidecar) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceSidecars. +func (in *InstanceSidecars) DeepCopy() *InstanceSidecars { + if in == nil { + return nil + } + out := new(InstanceSidecars) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MonitoringSpec) DeepCopyInto(out *MonitoringSpec) { + *out = *in + if in.PGMonitor != nil { + in, out := &in.PGMonitor, &out.PGMonitor + *out = new(v1beta1.PGMonitorSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MonitoringSpec. +func (in *MonitoringSpec) DeepCopy() *MonitoringSpec { + if in == nil { + return nil + } + out := new(MonitoringSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MonitoringStatus) DeepCopyInto(out *MonitoringStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MonitoringStatus. +func (in *MonitoringStatus) DeepCopy() *MonitoringStatus { + if in == nil { + return nil + } + out := new(MonitoringStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresCluster) DeepCopyInto(out *PostgresCluster) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresCluster. +func (in *PostgresCluster) DeepCopy() *PostgresCluster { + if in == nil { + return nil + } + out := new(PostgresCluster) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgresCluster) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresClusterDataSource) DeepCopyInto(out *PostgresClusterDataSource) { + *out = *in + if in.Options != nil { + in, out := &in.Options, &out.Options + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Resources.DeepCopyInto(&out.Resources) + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) + } + if in.PriorityClassName != nil { + in, out := &in.PriorityClassName, &out.PriorityClassName + *out = new(string) + **out = **in + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(v1beta1.PGBackRestVolumesSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresClusterDataSource. +func (in *PostgresClusterDataSource) DeepCopy() *PostgresClusterDataSource { + if in == nil { + return nil + } + out := new(PostgresClusterDataSource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresClusterList) DeepCopyInto(out *PostgresClusterList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PostgresCluster, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresClusterList. +func (in *PostgresClusterList) DeepCopy() *PostgresClusterList { + if in == nil { + return nil + } + out := new(PostgresClusterList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PostgresClusterList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresClusterSpec) DeepCopyInto(out *PostgresClusterSpec) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(v1beta1.Metadata) + (*in).DeepCopyInto(*out) + } + if in.DataSource != nil { + in, out := &in.DataSource, &out.DataSource + *out = new(DataSource) + (*in).DeepCopyInto(*out) + } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(v1beta1.PostgresAuthenticationSpec) + (*in).DeepCopyInto(*out) + } + in.Backups.DeepCopyInto(&out.Backups) + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(v1beta1.PostgresConfigSpec) + (*in).DeepCopyInto(*out) + } + if in.CustomTLSSecret != nil { + in, out := &in.CustomTLSSecret, &out.CustomTLSSecret + *out = new(corev1.SecretProjection) + (*in).DeepCopyInto(*out) + } + if in.CustomReplicationClientTLSSecret != nil { + in, out := &in.CustomReplicationClientTLSSecret, &out.CustomReplicationClientTLSSecret + *out = new(corev1.SecretProjection) + (*in).DeepCopyInto(*out) + } + if in.DatabaseInitSQL != nil { + in, out := &in.DatabaseInitSQL, &out.DatabaseInitSQL + *out = new(DatabaseInitSQL) + **out = **in + } + if in.DisableDefaultPodScheduling != nil { + in, out := &in.DisableDefaultPodScheduling, &out.DisableDefaultPodScheduling + *out = new(bool) + **out = **in + } + if in.ImagePullSecrets != nil { + in, out := &in.ImagePullSecrets, &out.ImagePullSecrets + *out = make([]corev1.LocalObjectReference, len(*in)) + copy(*out, *in) + } + if in.InstanceSets != nil { + in, out := &in.InstanceSets, &out.InstanceSets + *out = make([]PostgresInstanceSetSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Instrumentation != nil { + in, out := &in.Instrumentation, &out.Instrumentation + *out = new(v1beta1.InstrumentationSpec) + (*in).DeepCopyInto(*out) + } + if in.OpenShift != nil { + in, out := &in.OpenShift, &out.OpenShift + *out = new(bool) + **out = **in + } + if in.Patroni != nil { + in, out := &in.Patroni, &out.Patroni + *out = new(v1beta1.PatroniSpec) + (*in).DeepCopyInto(*out) + } + if in.Paused != nil { + in, out := &in.Paused, &out.Paused + *out = new(bool) + **out = **in + } + if in.Port != nil { + in, out := &in.Port, &out.Port + *out = new(int32) + **out = **in + } + if in.Proxy != nil { + in, out := &in.Proxy, &out.Proxy + *out = new(PostgresProxySpec) + (*in).DeepCopyInto(*out) + } + if in.UserInterface != nil { + in, out := &in.UserInterface, &out.UserInterface + *out = new(UserInterfaceSpec) + (*in).DeepCopyInto(*out) + } + if in.Monitoring != nil { + in, out := &in.Monitoring, &out.Monitoring + *out = new(MonitoringSpec) + (*in).DeepCopyInto(*out) + } + if in.Service != nil { + in, out := &in.Service, &out.Service + *out = new(v1beta1.ServiceSpec) + (*in).DeepCopyInto(*out) + } + if in.ReplicaService != nil { + in, out := &in.ReplicaService, &out.ReplicaService + *out = new(v1beta1.ServiceSpec) + (*in).DeepCopyInto(*out) + } + if in.Shutdown != nil { + in, out := &in.Shutdown, &out.Shutdown + *out = new(bool) + **out = **in + } + if in.Standby != nil { + in, out := &in.Standby, &out.Standby + *out = new(PostgresStandbySpec) + (*in).DeepCopyInto(*out) + } + if in.SupplementalGroups != nil { + in, out := &in.SupplementalGroups, &out.SupplementalGroups + *out = make([]int64, len(*in)) + copy(*out, *in) + } + if in.Users != nil { + in, out := &in.Users, &out.Users + *out = make([]v1beta1.PostgresUserSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresClusterSpec. +func (in *PostgresClusterSpec) DeepCopy() *PostgresClusterSpec { + if in == nil { + return nil + } + out := new(PostgresClusterSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresClusterStatus) DeepCopyInto(out *PostgresClusterStatus) { + *out = *in + if in.InstanceSets != nil { + in, out := &in.InstanceSets, &out.InstanceSets + *out = make([]PostgresInstanceSetStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.Patroni.DeepCopyInto(&out.Patroni) + if in.PGBackRest != nil { + in, out := &in.PGBackRest, &out.PGBackRest + *out = new(v1beta1.PGBackRestStatus) + (*in).DeepCopyInto(*out) + } + if in.RegistrationRequired != nil { + in, out := &in.RegistrationRequired, &out.RegistrationRequired + *out = new(RegistrationRequirementStatus) + **out = **in + } + out.Proxy = in.Proxy + if in.UserInterface != nil { + in, out := &in.UserInterface, &out.UserInterface + *out = new(PostgresUserInterfaceStatus) + **out = **in + } + out.Monitoring = in.Monitoring + if in.DatabaseInitSQL != nil { + in, out := &in.DatabaseInitSQL, &out.DatabaseInitSQL + *out = new(string) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresClusterStatus. +func (in *PostgresClusterStatus) DeepCopy() *PostgresClusterStatus { + if in == nil { + return nil + } + out := new(PostgresClusterStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresInstanceSetSpec) DeepCopyInto(out *PostgresInstanceSetSpec) { + *out = *in + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = new(v1beta1.Metadata) + (*in).DeepCopyInto(*out) + } + if in.Affinity != nil { + in, out := &in.Affinity, &out.Affinity + *out = new(corev1.Affinity) + (*in).DeepCopyInto(*out) + } + if in.Containers != nil { + in, out := &in.Containers, &out.Containers + *out = make([]corev1.Container, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + in.DataVolumeClaimSpec.DeepCopyInto(&out.DataVolumeClaimSpec) + if in.PriorityClassName != nil { + in, out := &in.PriorityClassName, &out.PriorityClassName + *out = new(string) + **out = **in + } + if in.Replicas != nil { + in, out := &in.Replicas, &out.Replicas + *out = new(int32) + **out = **in + } + if in.MinAvailable != nil { + in, out := &in.MinAvailable, &out.MinAvailable + *out = new(intstr.IntOrString) + **out = **in + } + in.Resources.DeepCopyInto(&out.Resources) + if in.Sidecars != nil { + in, out := &in.Sidecars, &out.Sidecars + *out = new(InstanceSidecars) + (*in).DeepCopyInto(*out) + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]corev1.Toleration, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]corev1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.WALVolumeClaimSpec != nil { + in, out := &in.WALVolumeClaimSpec, &out.WALVolumeClaimSpec + *out = (*in).DeepCopy() + } + if in.TablespaceVolumes != nil { + in, out := &in.TablespaceVolumes, &out.TablespaceVolumes + *out = make([]TablespaceVolume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(v1beta1.PostgresVolumesSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresInstanceSetSpec. +func (in *PostgresInstanceSetSpec) DeepCopy() *PostgresInstanceSetSpec { + if in == nil { + return nil + } + out := new(PostgresInstanceSetSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresInstanceSetStatus) DeepCopyInto(out *PostgresInstanceSetStatus) { + *out = *in + if in.DesiredPGDataVolume != nil { + in, out := &in.DesiredPGDataVolume, &out.DesiredPGDataVolume + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresInstanceSetStatus. +func (in *PostgresInstanceSetStatus) DeepCopy() *PostgresInstanceSetStatus { + if in == nil { + return nil + } + out := new(PostgresInstanceSetStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresProxySpec) DeepCopyInto(out *PostgresProxySpec) { + *out = *in + if in.PGBouncer != nil { + in, out := &in.PGBouncer, &out.PGBouncer + *out = new(v1beta1.PGBouncerPodSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresProxySpec. +func (in *PostgresProxySpec) DeepCopy() *PostgresProxySpec { + if in == nil { + return nil + } + out := new(PostgresProxySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresProxyStatus) DeepCopyInto(out *PostgresProxyStatus) { + *out = *in + out.PGBouncer = in.PGBouncer +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresProxyStatus. +func (in *PostgresProxyStatus) DeepCopy() *PostgresProxyStatus { + if in == nil { + return nil + } + out := new(PostgresProxyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresStandbySpec) DeepCopyInto(out *PostgresStandbySpec) { + *out = *in + if in.Port != nil { + in, out := &in.Port, &out.Port + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresStandbySpec. +func (in *PostgresStandbySpec) DeepCopy() *PostgresStandbySpec { + if in == nil { + return nil + } + out := new(PostgresStandbySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresUserInterfaceStatus) DeepCopyInto(out *PostgresUserInterfaceStatus) { + *out = *in + out.PGAdmin = in.PGAdmin +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresUserInterfaceStatus. +func (in *PostgresUserInterfaceStatus) DeepCopy() *PostgresUserInterfaceStatus { + if in == nil { + return nil + } + out := new(PostgresUserInterfaceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RegistrationRequirementStatus) DeepCopyInto(out *RegistrationRequirementStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegistrationRequirementStatus. +func (in *RegistrationRequirementStatus) DeepCopy() *RegistrationRequirementStatus { + if in == nil { + return nil + } + out := new(RegistrationRequirementStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TablespaceVolume) DeepCopyInto(out *TablespaceVolume) { + *out = *in + in.DataVolumeClaimSpec.DeepCopyInto(&out.DataVolumeClaimSpec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TablespaceVolume. +func (in *TablespaceVolume) DeepCopy() *TablespaceVolume { + if in == nil { + return nil + } + out := new(TablespaceVolume) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *UserInterfaceSpec) DeepCopyInto(out *UserInterfaceSpec) { + *out = *in + if in.PGAdmin != nil { + in, out := &in.PGAdmin, &out.PGAdmin + *out = new(v1beta1.PGAdminPodSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserInterfaceSpec. +func (in *UserInterfaceSpec) DeepCopy() *UserInterfaceSpec { + if in == nil { + return nil + } + out := new(UserInterfaceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VolumeSnapshots) DeepCopyInto(out *VolumeSnapshots) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolumeSnapshots. +func (in *VolumeSnapshots) DeepCopy() *VolumeSnapshots { + if in == nil { + return nil + } + out := new(VolumeSnapshots) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/config_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/config_types.go new file mode 100644 index 0000000000..e331130ed5 --- /dev/null +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/config_types.go @@ -0,0 +1,95 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package v1beta1 + +import ( + corev1 "k8s.io/api/core/v1" +) + +// +structType=atomic +type OptionalConfigMapKeyRef struct { + ConfigMapKeyRef `json:",inline"` + + // Whether or not the ConfigMap or its data must be defined. Defaults to false. + // +optional + Optional *bool `json:"optional,omitempty"` +} + +// AsProjection returns a copy of this as a [corev1.ConfigMapProjection]. +func (in *OptionalConfigMapKeyRef) AsProjection(path string) corev1.ConfigMapProjection { + out := in.ConfigMapKeyRef.AsProjection(path) + if in.Optional != nil { + v := *in.Optional + out.Optional = &v + } + return out +} + +// +structType=atomic +type ConfigMapKeyRef struct { + // Name of the ConfigMap. + // --- + // https://pkg.go.dev/k8s.io/kubernetes/pkg/apis/core/validation#ValidateConfigMapName + // +required + Name DNS1123Subdomain `json:"name"` + + // Name of the data field within the ConfigMap. + // --- + // https://github.com/kubernetes/kubernetes/blob/v1.32.0/pkg/apis/core/validation/validation.go#L2849 + // https://pkg.go.dev/k8s.io/apimachinery/pkg/util/validation#IsConfigMapKey + // +required + Key ConfigDataKey `json:"key"` +} + +// AsProjection returns a copy of this as a [corev1.ConfigMapProjection]. +func (in *ConfigMapKeyRef) AsProjection(path string) corev1.ConfigMapProjection { + var out corev1.ConfigMapProjection + out.Name = in.Name + out.Items = []corev1.KeyToPath{{Key: in.Key, Path: path}} + return out +} + +// +structType=atomic +type OptionalSecretKeyRef struct { + SecretKeyRef `json:",inline"` + + // Whether or not the Secret or its data must be defined. Defaults to false. + // +optional + Optional *bool `json:"optional,omitempty"` +} + +// AsProjection returns a copy of this as a [corev1.SecretProjection]. +func (in *OptionalSecretKeyRef) AsProjection(path string) corev1.SecretProjection { + out := in.SecretKeyRef.AsProjection(path) + if in.Optional != nil { + v := *in.Optional + out.Optional = &v + } + return out +} + +// +structType=atomic +type SecretKeyRef struct { + // Name of the Secret. + // --- + // https://pkg.go.dev/k8s.io/kubernetes/pkg/apis/core/validation#ValidateSecretName + // +required + Name DNS1123Subdomain `json:"name"` + + // Name of the data field within the Secret. + // --- + // https://releases.k8s.io/v1.32.0/pkg/apis/core/validation/validation.go#L2867 + // https://pkg.go.dev/k8s.io/apimachinery/pkg/util/validation#IsConfigMapKey + // +required + Key ConfigDataKey `json:"key"` +} + +// AsProjection returns a copy of this as a [corev1.SecretProjection]. +func (in *SecretKeyRef) AsProjection(path string) corev1.SecretProjection { + var out corev1.SecretProjection + out.Name = in.Name + out.Items = []corev1.KeyToPath{{Key: in.Key, Path: path}} + return out +} diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/config_types_test.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/config_types_test.go new file mode 100644 index 0000000000..7ef9bdf0e4 --- /dev/null +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/config_types_test.go @@ -0,0 +1,145 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package v1beta1_test + +import ( + "strings" + "testing" + + "gotest.tools/v3/assert" + "sigs.k8s.io/yaml" + + "github.com/crunchydata/postgres-operator/pkg/apis/postgres-operator.crunchydata.com/v1beta1" +) + +func TestOptionalConfigMapKeyRefAsProjection(t *testing.T) { + t.Run("Null", func(t *testing.T) { + in := v1beta1.OptionalConfigMapKeyRef{} + in.Name, in.Key = "one", "two" + + out := in.AsProjection("three") + b, err := yaml.Marshal(out) + assert.NilError(t, err) + assert.DeepEqual(t, string(b), strings.TrimSpace(` +items: +- key: two + path: three +name: one + `)+"\n") + }) + + t.Run("True", func(t *testing.T) { + True := true + in := v1beta1.OptionalConfigMapKeyRef{Optional: &True} + in.Name, in.Key = "one", "two" + + out := in.AsProjection("three") + b, err := yaml.Marshal(out) + assert.NilError(t, err) + assert.DeepEqual(t, string(b), strings.TrimSpace(` +items: +- key: two + path: three +name: one +optional: true + `)+"\n") + }) + + t.Run("False", func(t *testing.T) { + False := false + in := v1beta1.OptionalConfigMapKeyRef{Optional: &False} + in.Name, in.Key = "one", "two" + + out := in.AsProjection("three") + b, err := yaml.Marshal(out) + assert.NilError(t, err) + assert.DeepEqual(t, string(b), strings.TrimSpace(` +items: +- key: two + path: three +name: one +optional: false + `)+"\n") + }) +} + +func TestConfigMapKeyRefAsProjection(t *testing.T) { + in := v1beta1.ConfigMapKeyRef{Name: "asdf", Key: "foobar"} + out := in.AsProjection("some-path") + + b, err := yaml.Marshal(out) + assert.NilError(t, err) + assert.DeepEqual(t, string(b), strings.TrimSpace(` +items: +- key: foobar + path: some-path +name: asdf + `)+"\n") +} + +func TestOptionalSecretKeyRefAsProjection(t *testing.T) { + t.Run("Null", func(t *testing.T) { + in := v1beta1.OptionalSecretKeyRef{} + in.Name, in.Key = "one", "two" + + out := in.AsProjection("three") + b, err := yaml.Marshal(out) + assert.NilError(t, err) + assert.DeepEqual(t, string(b), strings.TrimSpace(` +items: +- key: two + path: three +name: one + `)+"\n") + }) + + t.Run("True", func(t *testing.T) { + True := true + in := v1beta1.OptionalSecretKeyRef{Optional: &True} + in.Name, in.Key = "one", "two" + + out := in.AsProjection("three") + b, err := yaml.Marshal(out) + assert.NilError(t, err) + assert.DeepEqual(t, string(b), strings.TrimSpace(` +items: +- key: two + path: three +name: one +optional: true + `)+"\n") + }) + + t.Run("False", func(t *testing.T) { + False := false + in := v1beta1.OptionalSecretKeyRef{Optional: &False} + in.Name, in.Key = "one", "two" + + out := in.AsProjection("three") + b, err := yaml.Marshal(out) + assert.NilError(t, err) + assert.DeepEqual(t, string(b), strings.TrimSpace(` +items: +- key: two + path: three +name: one +optional: false + `)+"\n") + }) +} + +func TestSecretKeyRefAsProjection(t *testing.T) { + in := v1beta1.SecretKeyRef{Name: "asdf", Key: "foobar"} + out := in.AsProjection("some-path") + + b, err := yaml.Marshal(out) + assert.NilError(t, err) + assert.DeepEqual(t, string(b), strings.TrimSpace(` +items: +- key: foobar + path: some-path +name: asdf + `)+"\n") +} diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/crunchy_bridgecluster_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/crunchy_bridgecluster_types.go index 0b94a4dae1..89b464a248 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/crunchy_bridgecluster_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/crunchy_bridgecluster_types.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -51,6 +51,11 @@ type CrunchyBridgeClusterSpec struct { // The cloud provider where the cluster is located. // Currently Bridge offers aws, azure, and gcp only + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=10 + // // +kubebuilder:validation:Required // +kubebuilder:validation:Enum={aws,azure,gcp} // +kubebuilder:validation:XValidation:rule=`self == oldSelf`,message="immutable" @@ -186,8 +191,10 @@ const ( ConditionDeleting = "Deleting" ) -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:storageversion +//+versionName=v1beta1 // +operator-sdk:csv:customresourcedefinitions:resources={{ConfigMap,v1},{Secret,v1},{Service,v1},{CronJob,v1beta1},{Deployment,v1},{Job,v1},{StatefulSet,v1},{PersistentVolumeClaim,v1}} // CrunchyBridgeCluster is the Schema for the crunchybridgeclusters API @@ -197,14 +204,17 @@ type CrunchyBridgeCluster struct { // - https://releases.k8s.io/v1.21.0/staging/src/k8s.io/apiextensions-apiserver/pkg/registry/customresource/validator.go#L60 // In Bridge json, meta.name is "name" - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` // NOTE(cbandy): Every CrunchyBridgeCluster needs a Spec, but it is optional here // so ObjectMeta can be managed independently. - Spec CrunchyBridgeClusterSpec `json:"spec,omitempty"` - Status CrunchyBridgeClusterStatus `json:"status,omitempty"` + // +optional + Spec CrunchyBridgeClusterSpec `json:"spec,omitzero"` + // +optional + Status CrunchyBridgeClusterStatus `json:"status,omitzero"` } // Default implements "sigs.k8s.io/controller-runtime/pkg/webhook.Defaulter" so @@ -224,7 +234,9 @@ func (c *CrunchyBridgeCluster) Default() { // CrunchyBridgeClusterList contains a list of CrunchyBridgeCluster type CrunchyBridgeClusterList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + + // +optional + metav1.ListMeta `json:"metadata,omitzero"` Items []CrunchyBridgeCluster `json:"items"` } diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/groupversion_info.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/groupversion_info.go index 15773a1815..a8ddca9804 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/groupversion_info.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/groupversion_info.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/instrumentation_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/instrumentation_types.go new file mode 100644 index 0000000000..3fd226f767 --- /dev/null +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/instrumentation_types.go @@ -0,0 +1,265 @@ +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. +// +// SPDX-License-Identifier: Apache-2.0 + +package v1beta1 + +import corev1 "k8s.io/api/core/v1" + +// InstrumentationSpec defines the configuration for collecting logs and metrics +// via OpenTelemetry. +type InstrumentationSpec struct { + // Image name to use for collector containers. When omitted, the value + // comes from an operator environment variable. + // --- + // +optional + Image string `json:"image,omitempty"` + + // Resources holds the resource requirements for the collector container. + // --- + // +optional + Resources corev1.ResourceRequirements `json:"resources,omitzero"` + + // Config is the place for users to configure exporters and provide files. + // --- + // +optional + Config *InstrumentationConfigSpec `json:"config,omitempty"` + + // Logs is the place for users to configure the log collection. + // --- + // +optional + Logs *InstrumentationLogsSpec `json:"logs,omitempty"` + + // Metrics is the place for users to configure metrics collection. + // --- + // +optional + Metrics *InstrumentationMetricsSpec `json:"metrics,omitempty"` +} + +// InstrumentationConfigSpec allows users to configure their own exporters, +// add files, etc. +type InstrumentationConfigSpec struct { + // Resource detectors add identifying attributes to logs and metrics. These run in the order they are defined. + // More info: https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/-/processor/resourcedetectionprocessor#readme + // --- + // +kubebuilder:validation:MaxItems=10 + // +kubebuilder:validation:MinItems=1 + // +listMapKey=name + // +listType=map + // +optional + Detectors []OpenTelemetryResourceDetector `json:"detectors,omitempty"` + + // Exporters allows users to configure OpenTelemetry exporters that exist + // in the collector image. + // --- + // +kubebuilder:pruning:PreserveUnknownFields + // +kubebuilder:validation:Schemaless + // +kubebuilder:validation:Type=object + // +optional + Exporters SchemalessObject `json:"exporters,omitempty"` + + // Files allows the user to mount projected volumes into the collector + // Pod so that files can be referenced by the collector as needed. + // --- + // +kubebuilder:validation:MinItems=1 + // +listType=atomic + // +optional + Files []corev1.VolumeProjection `json:"files,omitempty"` + + // EnvironmentVariables allows the user to add environment variables to the + // collector container. + // --- + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:items:XValidation:rule=`self.name != 'K8S_POD_NAMESPACE' && self.name != 'K8S_POD_NAME' && self.name != 'PGPASSWORD'`,message="Cannot overwrite environment variables set by operator" + // +listType=atomic + // +optional + EnvironmentVariables []corev1.EnvVar `json:"environmentVariables,omitempty"` +} + +// InstrumentationLogsSpec defines the configuration for collecting logs via +// OpenTelemetry. +type InstrumentationLogsSpec struct { + // Log records are exported in small batches. Set this field to change their size and frequency. + // --- + // +optional + Batches *OpenTelemetryLogsBatchSpec `json:"batches,omitempty"` + + // The names of exporters that should send logs. + // --- + // +kubebuilder:validation:MinItems=1 + // +listType=set + // +optional + Exporters []string `json:"exporters,omitempty"` + + // How long to retain log files locally. An RFC 3339 duration or a number + // and unit: `12 hr`, `3d`, `4 weeks`, etc. + // --- + // Kubernetes ensures the value is in the "duration" format, but go ahead + // and loosely validate the format to show some acceptable units. + // NOTE: This rejects fractional numbers: https://github.com/kubernetes/kube-openapi/issues/523 + // +kubebuilder:validation:Pattern=`^(PT)?( *[0-9]+ *(?i:(h|hr|d|w|wk)|(hour|day|week)s?))+$` + // + // `controller-gen` needs to know "Type=string" to allow a "Pattern". + // +kubebuilder:validation:Type=string + // + // Set a max length to keep rule costs low. + // +kubebuilder:validation:MaxLength=20 + // +kubebuilder:validation:XValidation:rule=`duration("1h") <= self && self <= duration("8760h")`,message="must be at least one hour" + // + // +optional + RetentionPeriod *Duration `json:"retentionPeriod,omitempty"` +} + +type InstrumentationMetricsSpec struct { + // Where users can turn off built-in metrics and also provide their own + // custom queries. + // --- + // +optional + CustomQueries *InstrumentationCustomQueriesSpec `json:"customQueries,omitempty"` + + // The names of exporters that should send metrics. + // --- + // +kubebuilder:validation:MinItems=1 + // +listType=set + // +optional + Exporters []string `json:"exporters,omitempty"` + + // User defined databases to target for default per-db metrics + // --- + // +optional + PerDBMetricTargets []string `json:"perDBMetricTargets,omitempty"` +} + +type InstrumentationCustomQueriesSpec struct { + // User defined queries and metrics. + // --- + // +optional + Add []InstrumentationCustomQueries `json:"add,omitempty"` + + // A list of built-in queries that should be removed. If all queries for a + // given SQL statement are removed, the SQL statement will no longer be run. + // --- + // +optional + Remove []string `json:"remove,omitempty"` +} + +type InstrumentationCustomQueries struct { + // The name of this batch of queries, which will be used in naming the OTel + // SqlQuery receiver. + // --- + // OTel restricts component names from having whitespace, control characters, + // or symbols. + // https://github.com/open-telemetry/opentelemetry-collector/blob/main/component/identifiable.go#L23-L26 + // +kubebuilder:validation:Pattern=`^[^\pZ\pC\pS]+$` + // + // Set a max length to keep rule costs low. + // +kubebuilder:validation:MaxLength=20 + // + // +required + Name string `json:"name"` + + // A ConfigMap holding the yaml file that contains the queries. + // --- + // +required + Queries ConfigMapKeyRef `json:"queries"` + + // How often the queries should be run. + // --- + // Kubernetes ensures the value is in the "duration" format, but go ahead + // and loosely validate the format to show some acceptable units. + // NOTE: This rejects fractional numbers: https://github.com/kubernetes/kube-openapi/issues/523 + // +kubebuilder:validation:Pattern=`^((PT)?( *[0-9]+ *(?i:(ms|s|m)|(milli|sec|min)s?))+|0)$` + // + // `controller-gen` needs to know "Type=string" to allow a "Pattern". + // +kubebuilder:validation:Type=string + // + // Set a max length to keep rule costs low. + // +kubebuilder:validation:MaxLength=20 + // +kubebuilder:validation:XValidation:rule=`duration("0") <= self && self <= duration("60m")` + // + // +default="5s" + // +optional + CollectionInterval *Duration `json:"collectionInterval,omitempty"` + + // The databases to target with added custom queries. + // Default behavior is to target `postgres`. + // --- + // +optional + Databases []string `json:"databases,omitempty"` +} + +// --- +// Configuration for the OpenTelemetry Batch Processor +// https://pkg.go.dev/go.opentelemetry.io/collector/processor/batchprocessor#section-readme +// --- +// The batch processor stops batching when *either* of these is zero, but that is confusing. +// Make the user set both so it is evident there is *no* motivation to create any batch. +// +kubebuilder:validation:XValidation:rule=`(has(self.minRecords) && self.minRecords == 0) == (has(self.maxDelay) && self.maxDelay == duration('0'))`,message=`to disable batching, both minRecords and maxDelay must be zero` +// +// +kubebuilder:validation:XValidation:rule=`!has(self.maxRecords) || self.minRecords <= self.maxRecords`,message=`minRecords cannot be larger than maxRecords` +// +structType=atomic +type OpenTelemetryLogsBatchSpec struct { + // Maximum time to wait before exporting a log record. Higher numbers + // allow more records to be deduplicated and compressed before export. + // --- + // Kubernetes ensures the value is in the "duration" format, but go ahead + // and loosely validate the format to show some acceptable units. + // NOTE: This rejects fractional numbers: https://github.com/kubernetes/kube-openapi/issues/523 + // +kubebuilder:validation:Pattern=`^((PT)?( *[0-9]+ *(?i:(ms|s|m)|(milli|sec|min)s?))+|0)$` + // + // `controller-gen` needs to know "Type=string" to allow a "Pattern". + // +kubebuilder:validation:Type=string + // + // Set a max length to keep rule costs low. + // +kubebuilder:validation:MaxLength=20 + // +kubebuilder:validation:XValidation:rule=`duration("0") <= self && self <= duration("5m")` + // + // +default="200ms" + // +optional + MaxDelay *Duration `json:"maxDelay,omitempty"` + + // Maximum number of records to include in an exported batch. When present, + // batches this size are sent without any further delay. + // --- + // +kubebuilder:validation:Minimum=1 + // +optional + MaxRecords *int32 `json:"maxRecords,omitempty"` + + // Number of records to wait for before exporting a batch. Higher numbers + // allow more records to be deduplicated and compressed before export. + // --- + // +kubebuilder:validation:Minimum=0 + // +default=8192 + // +optional + MinRecords *int32 `json:"minRecords,omitempty"` +} + +func (s *OpenTelemetryLogsBatchSpec) Default() { + if s.MaxDelay == nil { + s.MaxDelay, _ = NewDuration("200ms") + } + if s.MinRecords == nil { + s.MinRecords = new(int32) + *s.MinRecords = 8192 + } +} + +// --- +// +structType=atomic +type OpenTelemetryResourceDetector struct { + // Name of the resource detector to enable: `aks`, `eks`, `gcp`, etc. + // --- + // +kubebuilder:validation:MaxLength=20 + // +kubebuilder:validation:MinLength=1 + // +required + Name string `json:"name"` + + // Attributes to use from this detector. Detectors usually add every attribute + // they know automatically. Names omitted here behave according to detector defaults. + // --- + // +kubebuilder:validation:MaxProperties=30 + // +kubebuilder:validation:MinProperties=1 + // +mapType=atomic + // +optional + Attributes map[string]bool `json:"attributes,omitempty"` +} diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/patroni_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/patroni_types.go index 2f01399372..5ab1b2792c 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/patroni_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/patroni_types.go @@ -1,9 +1,11 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package v1beta1 +import "k8s.io/apimachinery/pkg/api/resource" + type PatroniSpec struct { // Patroni dynamic configuration settings. Changes to this value will be // automatically reloaded without validation. Changes to certain PostgreSQL @@ -23,6 +25,10 @@ type PatroniSpec struct { // +kubebuilder:validation:Minimum=3 LeaderLeaseDurationSeconds *int32 `json:"leaderLeaseDurationSeconds,omitempty"` + // Patroni log configuration settings. + // +optional + Logging *PatroniLogConfig `json:"logging,omitempty"` + // The port on which Patroni should listen. // Changing this value causes PostgreSQL to restart. // +optional @@ -48,6 +54,30 @@ type PatroniSpec struct { // - https://patroni.readthedocs.io/en/latest/kubernetes.html } +type PatroniLogConfig struct { + + // Limits the total amount of space taken by Patroni log files. + // Minimum value is 25MB. + // More info: https://kubernetes.io/docs/reference/kubernetes-api/common-definitions/quantity + // --- + // TODO(validation) TODO(k8s-1.29): Validate the minimum using CEL libraries. + // + // +required + StorageLimit *resource.Quantity `json:"storageLimit"` + + // The Patroni log level. + // More info: https://docs.python.org/3/library/logging.html#levels + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=10 + // + // +default="INFO" + // +kubebuilder:validation:Enum={CRITICAL,ERROR,WARNING,INFO,DEBUG,NOTSET} + // +optional + Level *string `json:"level,omitempty"` +} + type PatroniSwitchover struct { // Whether or not the operator should allow switchovers in a PostgresCluster @@ -65,6 +95,11 @@ type PatroniSwitchover struct { // "Failover" forces a particular instance to be primary, regardless of other // factors. A TargetInstance must be specified to failover. // NOTE: The Failover type is reserved as the "last resort" case. + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 + // // +kubebuilder:validation:Enum={Switchover,Failover} // +kubebuilder:default:=Switchover // +optional diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgadmin_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgadmin_types.go index 06c7321bc4..3b8c62f2e0 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgadmin_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgadmin_types.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -17,7 +17,7 @@ type PGAdminConfiguration struct { // A Secret containing the value for the LDAP_BIND_PASSWORD setting. // More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html // +optional - LDAPBindPassword *corev1.SecretKeySelector `json:"ldapBindPassword,omitempty"` + LDAPBindPassword *OptionalSecretKeyRef `json:"ldapBindPassword,omitempty"` // Settings for the pgAdmin server process. Keys should be uppercase and // values must be constants. @@ -44,12 +44,13 @@ type PGAdminPodSpec struct { // values will be loaded without validation. Be careful, as // you may put pgAdmin into an unusable state. // +optional - Config PGAdminConfiguration `json:"config,omitempty"` + Config PGAdminConfiguration `json:"config,omitzero"` // Defines a PersistentVolumeClaim for pgAdmin data. // More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes - // +kubebuilder:validation:Required - DataVolumeClaimSpec corev1.PersistentVolumeClaimSpec `json:"dataVolumeClaimSpec"` + // --- + // +required + DataVolumeClaimSpec VolumeClaimSpec `json:"dataVolumeClaimSpec"` // Name of a container image that can run pgAdmin 4. Changing this value causes // pgAdmin to restart. The image may also be set using the RELATED_IMAGE_PGADMIN @@ -75,7 +76,7 @@ type PGAdminPodSpec struct { // pgAdmin to restart. // More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Specification of the service that exposes pgAdmin. // +optional diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgbackrest_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgbackrest_types.go index 3e3098a602..1fdd2c536c 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgbackrest_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgbackrest_types.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -153,7 +153,7 @@ type BackupJobs struct { // Resource limits for backup jobs. Includes manual, scheduled and replica // create backups // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Priority class name for the pgBackRest backup Job pods. // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ @@ -175,6 +175,21 @@ type BackupJobs struct { // +optional // +kubebuilder:validation:Minimum=60 TTLSecondsAfterFinished *int32 `json:"ttlSecondsAfterFinished,omitempty"` + + // Volumes to add to Backup Job Pods + // +optional + Volumes *PGBackRestVolumesSpec `json:"volumes,omitempty"` +} + +// PGBackRestVolumesSpec defines the configuration for pgBackRest additional volumes +type PGBackRestVolumesSpec struct { + // Additional pre-existing volumes to add to the pod. + // --- + // +optional + // +listType=map + // +listMapKey=name + // +kubebuilder:validation:MaxItems=10 + Additional []AdditionalVolume `json:"additional,omitempty"` } // PGBackRestManualBackup contains information that is used for creating a @@ -208,7 +223,7 @@ type PGBackRestRepoHost struct { // Resource requirements for a pgBackRest repository host // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Tolerations of a PgBackRest repo host pod. Changing this value causes a restart. // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration @@ -230,6 +245,10 @@ type PGBackRestRepoHost struct { // Deprecated: Repository hosts use mTLS for encryption, authentication, and authorization. // +optional SSHSecret *corev1.SecretProjection `json:"sshSecret,omitempty"` + + // Volumes to add to the Repo Host Pod + // +optional + Volumes *PGBackRestVolumesSpec `json:"volumes,omitempty"` } // PGBackRestRestore defines an in-place restore for the PostgresCluster. @@ -343,20 +362,8 @@ type RepoPVC struct { // Defines a PersistentVolumeClaim spec used to create and/or bind a volume // --- - // +kubebuilder:validation:Required - // - // NOTE(validation): Every PVC must have at least one accessMode. NOTE(KEP-4153) - // TODO(k8s-1.28): fieldPath=`.accessModes`,reason="FieldValueRequired" - // - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2098-L2100 - // - https://releases.k8s.io/v1.31.0/pkg/apis/core/validation/validation.go#L2292-L2294 - // +kubebuilder:validation:XValidation:rule=`has(self.accessModes) && size(self.accessModes) > 0`,message=`missing accessModes` - // - // NOTE(validation): Every PVC must have a positive storage request. NOTE(KEP-4153) - // TODO(k8s-1.28): fieldPath=`.resources.requests.storage`,reason="FieldValueRequired" - // - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2126-L2133 - // - https://releases.k8s.io/v1.31.0/pkg/apis/core/validation/validation.go#L2318-L2325 - // +kubebuilder:validation:XValidation:rule=`has(self.resources) && has(self.resources.requests) && has(self.resources.requests.storage)`,message=`missing storage request` - VolumeClaimSpec corev1.PersistentVolumeClaimSpec `json:"volumeClaimSpec"` + // +required + VolumeClaimSpec VolumeClaimSpec `json:"volumeClaimSpec"` } // RepoAzure represents a pgBackRest repository that is created using Azure storage @@ -454,7 +461,7 @@ type PGBackRestDataSource struct { // Resource requirements for the pgBackRest restore Job. // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Scheduling constraints of the pgBackRest restore Job. // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node @@ -471,4 +478,8 @@ type PGBackRestDataSource struct { // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration // +optional Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // Volumes to add to Restore Job Pods + // +optional + Volumes *PGBackRestVolumesSpec `json:"volumes,omitempty"` } diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgbouncer_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgbouncer_types.go index e940a9300d..49e713c17a 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgbouncer_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgbouncer_types.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -60,7 +60,7 @@ type PGBouncerPodSpec struct { // you may put PgBouncer into an unusable state. // More info: https://www.pgbouncer.org/usage.html#reload // +optional - Config PGBouncerConfiguration `json:"config,omitempty"` + Config PGBouncerConfiguration `json:"config,omitzero"` // Custom sidecars for a PgBouncer pod. Changing this value causes // PgBouncer to restart. @@ -110,7 +110,7 @@ type PGBouncerPodSpec struct { // PgBouncer to restart. // More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Specification of the service that exposes PgBouncer. // +optional @@ -131,6 +131,19 @@ type PGBouncerPodSpec struct { // More info: https://kubernetes.io/docs/concepts/workloads/pods/pod-topology-spread-constraints/ // +optional TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + + Volumes *PGBouncerVolumesSpec `json:"volumes,omitempty"` +} + +// PGBouncerVolumesSpec defines the configuration for pgBouncer additional volumes +type PGBouncerVolumesSpec struct { + // Additional pre-existing volumes to add to the pod. + // --- + // +optional + // +listType=map + // +listMapKey=name + // +kubebuilder:validation:MaxItems=10 + Additional []AdditionalVolume `json:"additional,omitempty"` } // PGBouncerSidecars defines the configuration for pgBouncer sidecar containers diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgmonitor_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgmonitor_types.go index f2cd78335a..bbb3136aff 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgmonitor_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgmonitor_types.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -35,5 +35,5 @@ type ExporterSpec struct { // Changing this value causes PostgreSQL and the exporter to restart. // More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` } diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgupgrade_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgupgrade_types.go index 8e99f8239f..805ce1a16d 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgupgrade_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/pgupgrade_types.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -15,9 +15,10 @@ type PGUpgradeSpec struct { // +optional Metadata *Metadata `json:"metadata,omitempty"` - // The name of the cluster to be updated - // +required + // The name of the Postgres cluster to upgrade. + // --- // +kubebuilder:validation:MinLength=1 + // +required PostgresClusterName string `json:"postgresClusterName"` // The image name to use for major PostgreSQL upgrades. @@ -27,6 +28,12 @@ type PGUpgradeSpec struct { // ImagePullPolicy is used to determine when Kubernetes will attempt to // pull (download) container images. // More info: https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 + // +kubebuilder:validation:Type=string + // // +kubebuilder:validation:Enum={Always,Never,IfNotPresent} // +optional ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` @@ -36,41 +43,13 @@ type PGUpgradeSpec struct { // The image pull secrets used to pull from a private registry. // Changing this value causes all running PGUpgrade pods to restart. - // https://k8s.io/docs/tasks/configure-pod-container/pull-image-private-registry/ + // https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry // +optional ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` - // TODO(benjaminjb): define webhook validation to make sure - // `fromPostgresVersion` is below `toPostgresVersion` - // or leverage other validation rules, such as the Common Expression Language - // rules currently in alpha as of Kubernetes 1.23 - // - https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#validation-rules - - // The major version of PostgreSQL before the upgrade. - // +kubebuilder:validation:Required - // +kubebuilder:validation:Minimum=11 - // +kubebuilder:validation:Maximum=17 - FromPostgresVersion int `json:"fromPostgresVersion"` - - // TODO(benjaminjb): define webhook validation to make sure - // `fromPostgresVersion` is below `toPostgresVersion` - // or leverage other validation rules, such as the Common Expression Language - // rules currently in alpha as of Kubernetes 1.23 - - // The major version of PostgreSQL to be upgraded to. - // +kubebuilder:validation:Required - // +kubebuilder:validation:Minimum=11 - // +kubebuilder:validation:Maximum=17 - ToPostgresVersion int `json:"toPostgresVersion"` - - // The image name to use for PostgreSQL containers after upgrade. - // When omitted, the value comes from an operator environment variable. - // +optional - ToPostgresImage string `json:"toPostgresImage,omitempty"` - // Resource requirements for the PGUpgrade container. // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Scheduling constraints of the PGUpgrade pod. // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node @@ -82,7 +61,7 @@ type PGUpgradeSpec struct { // Priority class name for the PGUpgrade pod. Changing this // value causes PGUpgrade pod to restart. - // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption/ + // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/pod-priority-preemption // +optional PriorityClassName *string `json:"priorityClassName,omitempty"` @@ -90,6 +69,54 @@ type PGUpgradeSpec struct { // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration // +optional Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + PGUpgradeSettings `json:",inline"` +} + +// Arguments and settings for the pg_upgrade tool. +// See: https://www.postgresql.org/docs/current/pgupgrade.html +// --- +// +kubebuilder:validation:XValidation:rule=`self.fromPostgresVersion < self.toPostgresVersion` +// +kubebuilder:validation:XValidation:rule=`!has(self.transferMethod) || (self.toPostgresVersion < 12 ? self.transferMethod in ["Copy","Link"] : true)`,message="Only Copy or Link before PostgreSQL 12" +// +kubebuilder:validation:XValidation:rule=`!has(self.transferMethod) || (self.toPostgresVersion < 17 ? self.transferMethod in ["Clone","Copy","Link"] : true)`,message="Only Clone, Copy, or Link before PostgreSQL 17" +type PGUpgradeSettings struct { + + // The major version of PostgreSQL before the upgrade. + // --- + // +kubebuilder:validation:Minimum=11 + // +kubebuilder:validation:Maximum=17 + // +required + FromPostgresVersion int32 `json:"fromPostgresVersion"` + + // The number of simultaneous processes pg_upgrade should use. + // More info: https://www.postgresql.org/docs/current/pgupgrade.html + // --- + // +kubebuilder:validation:Minimum=0 + // +optional + Jobs int32 `json:"jobs,omitempty"` + + // The major version of PostgreSQL to be upgraded to. + // --- + // +kubebuilder:validation:Minimum=11 + // +kubebuilder:validation:Maximum=17 + // +required + ToPostgresVersion int32 `json:"toPostgresVersion"` + + // The method pg_upgrade should use to transfer files to the new cluster. + // More info: https://www.postgresql.org/docs/current/pgupgrade.html + // --- + // Different versions of the tool have different methods. + // - Copy and Link forever: https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/bin/pg_upgrade/pg_upgrade.h;hb=REL_10_0#l232 + // - Clone since 12: https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/bin/pg_upgrade/pg_upgrade.h;hb=REL_12_0#l232 + // - CopyFileRange since 17: https://git.postgresql.org/gitweb/?p=postgresql.git;f=src/bin/pg_upgrade/pg_upgrade.h;hb=REL_17_0#l251 + // + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 + // + // +kubebuilder:validation:Enum={Clone,Copy,CopyFileRange,Link} + // +optional + TransferMethod string `json:"transferMethod,omitempty"` } // PGUpgradeStatus defines the observed state of PGUpgrade @@ -108,14 +135,19 @@ type PGUpgradeStatus struct { //+kubebuilder:object:root=true //+kubebuilder:subresource:status +//+kubebuilder:storageversion +//+versionName=v1beta1 // PGUpgrade is the Schema for the pgupgrades API type PGUpgrade struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` - Spec PGUpgradeSpec `json:"spec,omitempty"` - Status PGUpgradeStatus `json:"status,omitempty"` + // +optional + Spec PGUpgradeSpec `json:"spec,omitzero"` + // +optional + Status PGUpgradeStatus `json:"status,omitzero"` } //+kubebuilder:object:root=true @@ -123,7 +155,7 @@ type PGUpgrade struct { // PGUpgradeList contains a list of PGUpgrade type PGUpgradeList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + metav1.ListMeta `json:"metadata,omitzero"` Items []PGUpgrade `json:"items"` } diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgres_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgres_types.go index b7baa72942..06658065b6 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgres_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgres_types.go @@ -1,23 +1,187 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package v1beta1 -// PostgreSQL identifiers are limited in length but may contain any character. -// More info: https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/intstr" +) + +type PostgresAuthenticationSpec struct { + // Postgres compares every new connection to these rules in the order they are + // defined. The first rule that matches determines if and how the connection + // must then authenticate. Connections that match no rules are disconnected. + // + // When this is omitted or empty, Postgres accepts encrypted connections to any + // database from users that have a password. To refuse all network connections, + // set this to one rule that matches "host" connections to the "reject" method. + // + // More info: https://www.postgresql.org/docs/current/auth-pg-hba-conf.html + // --- + // +kubebuilder:validation:MaxItems=10 + // +listType=atomic + // +optional + Rules []PostgresHBARuleSpec `json:"rules,omitempty"` +} + +type PostgresConfigSpec struct { + // Files to mount under "/etc/postgres". + // --- + // +optional + Files []corev1.VolumeProjection `json:"files,omitempty"` + + // Configuration parameters for the PostgreSQL server. Some values will + // be reloaded without validation and some cause PostgreSQL to restart. + // Some values cannot be changed at all. + // More info: https://www.postgresql.org/docs/current/runtime-config.html + // --- + // + // Postgres 17 has something like 350+ built-in parameters, but typically + // an administrator will change only a handful of these. + // +kubebuilder:validation:MaxProperties=50 + // + // # File Locations + // - https://www.postgresql.org/docs/current/runtime-config-file-locations.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.config_file) && !has(self.data_directory)`,message=`cannot change PGDATA path: config_file, data_directory` + // +kubebuilder:validation:XValidation:rule=`!has(self.external_pid_file)`,message=`cannot change external_pid_file` + // +kubebuilder:validation:XValidation:rule=`!has(self.hba_file) && !has(self.ident_file)`,message=`cannot change authentication path: hba_file, ident_file` + // + // # Connections + // - https://www.postgresql.org/docs/current/runtime-config-connection.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.listen_addresses)`,message=`network connectivity is always enabled: listen_addresses` + // +kubebuilder:validation:XValidation:rule=`!has(self.port)`,message=`change port using .spec.port instead` + // +kubebuilder:validation:XValidation:rule=`!has(self.ssl) && !self.exists(k, k.startsWith("ssl_"))`,message=`TLS is always enabled` + // +kubebuilder:validation:XValidation:rule=`!self.exists(k, k.startsWith("unix_socket_"))`,message=`domain socket paths cannot be changed` + // + // # Write Ahead Log + // - https://www.postgresql.org/docs/current/runtime-config-wal.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.wal_level) || self.wal_level in ["logical"]`,message=`wal_level must be "replica" or higher` + // +kubebuilder:validation:XValidation:rule=`!has(self.wal_log_hints)`,message=`wal_log_hints are always enabled` + // +kubebuilder:validation:XValidation:rule=`!has(self.archive_mode) && !has(self.archive_command) && !has(self.restore_command)` + // +kubebuilder:validation:XValidation:rule=`!has(self.recovery_target) && !self.exists(k, k.startsWith("recovery_target_"))` + // + // # Replication + // - https://www.postgresql.org/docs/current/runtime-config-replication.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.hot_standby)`,message=`hot_standby is always enabled` + // +kubebuilder:validation:XValidation:rule=`!has(self.synchronous_standby_names)` + // +kubebuilder:validation:XValidation:rule=`!has(self.primary_conninfo) && !has(self.primary_slot_name)` + // +kubebuilder:validation:XValidation:rule=`!has(self.recovery_min_apply_delay)`,message=`delayed replication is not supported at this time` + // + // # Logging + // - https://www.postgresql.org/docs/current/runtime-config-logging.html + // + // +kubebuilder:validation:XValidation:rule=`!has(self.cluster_name)`,message=`cluster_name is derived from the PostgresCluster name` + // +kubebuilder:validation:XValidation:rule=`!has(self.logging_collector)`,message=`disabling logging_collector is unsafe` + // +kubebuilder:validation:XValidation:rule=`!has(self.log_file_mode)`,message=`log_file_mode cannot be changed` + // + // +mapType=granular + // +optional + Parameters map[string]intstr.IntOrString `json:"parameters,omitempty"` +} + +// --- +type PostgresHBARule struct { + // The connection transport this rule matches. Typical values are: + // 1. "host" for network connections that may or may not be encrypted. + // 2. "hostssl" for network connections encrypted using TLS. + // 3. "hostgssenc" for network connections encrypted using GSSAPI. + // --- + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=20 + // +kubebuilder:validation:Pattern=`^[-a-z0-9]+$` + // +optional + Connection string `json:"connection,omitempty"` + + // Which databases this rule matches. When omitted or empty, this rule matches all databases. + // --- + // +kubebuilder:validation:MaxItems=20 + // +listType=atomic + // +optional + Databases []PostgresIdentifier `json:"databases,omitempty"` + + // The authentication method to use when a connection matches this rule. + // The special value "reject" refuses connections that match this rule. + // + // More info: https://www.postgresql.org/docs/current/auth-methods.html + // --- + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=20 + // +kubebuilder:validation:Pattern=`^[-a-z0-9]+$` + // +kubebuilder:validation:XValidation:rule=`self != "trust"`,message=`the "trust" method is unsafe` + // +optional + Method string `json:"method,omitempty"` + + // Additional settings for this rule or its authentication method. + // --- + // +kubebuilder:validation:MaxProperties=20 + // +mapType=atomic + // +optional + Options map[string]intstr.IntOrString `json:"options,omitempty"` + + // Which user names this rule matches. When omitted or empty, this rule matches all users. + // --- + // +kubebuilder:validation:MaxItems=20 + // +listType=atomic + // +optional + Users []PostgresIdentifier `json:"users,omitempty"` +} + +// --- +// Emulate OpenAPI "anyOf" aka Kubernetes union. +// +kubebuilder:validation:XValidation:rule=`[has(self.hba), has(self.connection) || has(self.databases) || has(self.method) || has(self.options) || has(self.users)].exists_one(b,b)`,message=`"hba" cannot be combined with other fields` +// +kubebuilder:validation:XValidation:rule=`has(self.hba) || (has(self.connection) && has(self.method))`,message=`"connection" and "method" are required` // +// Some authentication methods *must* be further configured via options. +// +// https://git.postgresql.org/gitweb/?p=postgresql.git;hb=refs/tags/REL_10_0;f=src/backend/libpq/hba.c#l1501 +// https://git.postgresql.org/gitweb/?p=postgresql.git;hb=refs/tags/REL_17_0;f=src/backend/libpq/hba.c#l1886 +// +kubebuilder:validation:XValidation:message=`the "ldap" method requires an "ldapbasedn", "ldapprefix", or "ldapsuffix" option`,rule=`has(self.hba) || self.method != "ldap" || (has(self.options) && ["ldapbasedn","ldapprefix","ldapsuffix"].exists(k, k in self.options))` +// +kubebuilder:validation:XValidation:message=`cannot use "ldapbasedn", "ldapbinddn", "ldapbindpasswd", "ldapsearchattribute", or "ldapsearchfilter" options with "ldapprefix" or "ldapsuffix" options`,rule=`has(self.hba) || self.method != "ldap" || !has(self.options) || 2 > size([["ldapprefix","ldapsuffix"], ["ldapbasedn","ldapbinddn","ldapbindpasswd","ldapsearchattribute","ldapsearchfilter"]].filter(a, a.exists(k, k in self.options)))` +// +// https://git.postgresql.org/gitweb/?p=postgresql.git;hb=refs/tags/REL_10_0;f=src/backend/libpq/hba.c#l1539 +// https://git.postgresql.org/gitweb/?p=postgresql.git;hb=refs/tags/REL_17_0;f=src/backend/libpq/hba.c#l1945 +// +kubebuilder:validation:XValidation:message=`the "radius" method requires "radiusservers" and "radiussecrets" options`,rule=`has(self.hba) || self.method != "radius" || (has(self.options) && ["radiusservers","radiussecrets"].all(k, k in self.options))` +// +// +structType=atomic +type PostgresHBARuleSpec struct { + // One line of the "pg_hba.conf" file. Changes to this value will be automatically reloaded without validation. + // --- + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=100 + // +kubebuilder:validation:Pattern=`^[[:print:]]+$` + // +kubebuilder:validation:XValidation:rule=`!self.trim().startsWith("include")`,message=`cannot include other files` + // +optional + HBA string `json:"hba,omitempty"` + + PostgresHBARule `json:",inline"` +} + +// --- +// PostgreSQL identifiers are limited in length but may contain any character. +// - https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=63 -type PostgresIdentifier string +type PostgresIdentifier = string type PostgresPasswordSpec struct { // Type of password to generate. Defaults to ASCII. Valid options are ASCII // and AlphaNumeric. // "ASCII" passwords contain letters, numbers, and symbols from the US-ASCII character set. // "AlphaNumeric" passwords contain letters and numbers from the US-ASCII character set. + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 + // // +kubebuilder:default=ASCII // +kubebuilder:validation:Enum={ASCII,AlphaNumeric} + // +required Type string `json:"type"` } @@ -28,20 +192,24 @@ const ( ) type PostgresUserSpec struct { - - // This value goes into the name of a corev1.Secret and a label value, so - // it must match both IsDNS1123Subdomain and IsValidLabelValue. The pattern - // below is IsDNS1123Subdomain without any dots, U+002E. - // The name of this PostgreSQL user. The value may contain only lowercase // letters, numbers, and hyphen so that it fits into Kubernetes metadata. + // --- + // This value goes into the name of a corev1.Secret and a label value, so + // it must match both IsDNS1123Subdomain and IsValidLabelValue. + // - https://pkg.go.dev/k8s.io/apimachinery/pkg/util/validation#IsDNS1123Subdomain + // - https://pkg.go.dev/k8s.io/apimachinery/pkg/util/validation#IsValidLabelValue + // + // This is IsDNS1123Subdomain without any dots, U+002E: // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` - // +kubebuilder:validation:Type=string + // + // +required Name PostgresIdentifier `json:"name"` // Databases to which this user can connect and create objects. Removing a // database from this list does NOT revoke access. This field is ignored for // the "postgres" user. + // --- // +listType=set // +optional Databases []PostgresIdentifier `json:"databases,omitempty"` @@ -49,6 +217,7 @@ type PostgresUserSpec struct { // ALTER ROLE options except for PASSWORD. This field is ignored for the // "postgres" user. // More info: https://www.postgresql.org/docs/current/role-attributes.html + // --- // +kubebuilder:validation:MaxLength=200 // +kubebuilder:validation:Pattern=`^[^;]*$` // +kubebuilder:validation:XValidation:rule=`!self.matches("(?i:PASSWORD)")`,message="cannot assign password" @@ -57,6 +226,7 @@ type PostgresUserSpec struct { Options string `json:"options,omitempty"` // Properties of the password generated for this user. + // --- // +optional Password *PostgresPasswordSpec `json:"password,omitempty"` } diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types.go index 54e42baa3b..f053345c88 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -21,9 +21,17 @@ type PostgresClusterSpec struct { // +optional DataSource *DataSource `json:"dataSource,omitempty"` + // Authentication settings for the PostgreSQL server + // +optional + Authentication *PostgresAuthenticationSpec `json:"authentication,omitempty"` + // PostgreSQL backup configuration // +optional - Backups Backups `json:"backups,omitempty"` + Backups Backups `json:"backups,omitzero"` + + // General configuration of the PostgreSQL server + // +optional + Config *PostgresConfigSpec `json:"config,omitempty"` // The secret containing the Certificates and Keys to encrypt PostgreSQL // traffic will need to contain the server TLS certificate, TLS key and the @@ -71,6 +79,12 @@ type PostgresClusterSpec struct { // ImagePullPolicy is used to determine when Kubernetes will attempt to // pull (download) container images. // More info: https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 + // +kubebuilder:validation:Type=string + // // +kubebuilder:validation:Enum={Always,Never,IfNotPresent} // +optional ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` @@ -89,6 +103,11 @@ type PostgresClusterSpec struct { // +operator-sdk:csv:customresourcedefinitions:type=spec,order=2 InstanceSets []PostgresInstanceSetSpec `json:"instances"` + // Configuration for the OpenTelemetry collector container used to collect + // logs and metrics. + // +optional + Instrumentation *InstrumentationSpec `json:"instrumentation,omitempty"` + // Whether or not the PostgreSQL cluster is being deployed to an OpenShift // environment. If the field is unset, the operator will automatically // detect the environment. @@ -177,8 +196,6 @@ type PostgresClusterSpec struct { // +kubebuilder:validation:MaxItems=64 // +optional Users []PostgresUserSpec `json:"users,omitempty"` - - Config PostgresAdditionalConfig `json:"config,omitempty"` } // DataSource defines data sources for a new PostgresCluster. @@ -188,6 +205,7 @@ type DataSource struct { // The PGBackRest field is incompatible with the PostgresCluster field: only one // data source can be used for pre-populating a new PostgreSQL cluster // +optional + // +kubebuilder:validation:XValidation:rule="!has(self.repo.volume)", message="Only S3, GCS or Azure repos can be used as a pgBackRest data source.", fieldPath=".repo" PGBackRest *PGBackRestDataSource `json:"pgbackrest,omitempty"` // Defines a pgBackRest data source that can be used to pre-populate the PostgreSQL data @@ -273,7 +291,7 @@ type PostgresClusterDataSource struct { // Resource requirements for the pgBackRest restore Job. // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Scheduling constraints of the pgBackRest restore Job. // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node @@ -290,6 +308,10 @@ type PostgresClusterDataSource struct { // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration // +optional Tolerations []corev1.Toleration `json:"tolerations,omitempty"` + + // Volumes to add to Restore Job Pods + // +optional + Volumes *PGBackRestVolumesSpec `json:"volumes,omitempty"` } // Default defines several key default values for a Postgres cluster. @@ -342,7 +364,7 @@ type PostgresClusterStatus struct { InstanceSets []PostgresInstanceSetStatus `json:"instances,omitempty"` // +optional - Patroni PatroniStatus `json:"patroni,omitempty"` + Patroni PatroniStatus `json:"patroni,omitzero"` // Status information for pgBackRest // +optional @@ -361,7 +383,7 @@ type PostgresClusterStatus struct { // Current state of the PostgreSQL proxy. // +optional - Proxy PostgresProxyStatus `json:"proxy,omitempty"` + Proxy PostgresProxyStatus `json:"proxy,omitzero"` // The instance that should be started first when bootstrapping and/or starting a // PostgresCluster. @@ -381,7 +403,7 @@ type PostgresClusterStatus struct { // Current state of PostgreSQL cluster monitoring tool configuration // +optional - Monitoring MonitoringStatus `json:"monitoring,omitempty"` + Monitoring MonitoringStatus `json:"monitoring,omitzero"` // DatabaseInitSQL state of custom database initialization in the cluster // +optional @@ -404,10 +426,11 @@ type PostgresClusterStatus struct { // PostgresClusterStatus condition types. const ( - PersistentVolumeResizing = "PersistentVolumeResizing" - PostgresClusterProgressing = "Progressing" - ProxyAvailable = "ProxyAvailable" - Registered = "Registered" + PersistentVolumeResizing = "PersistentVolumeResizing" + PersistentVolumeResizeError = "PersistentVolumeResizeError" + PostgresClusterProgressing = "Progressing" + ProxyAvailable = "ProxyAvailable" + Registered = "Registered" ) type PostgresInstanceSetSpec struct { @@ -451,20 +474,8 @@ type PostgresInstanceSetSpec struct { // Defines a PersistentVolumeClaim for PostgreSQL data. // More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes // --- - // +kubebuilder:validation:Required - // - // NOTE(validation): Every PVC must have at least one accessMode. NOTE(KEP-4153) - // TODO(k8s-1.28): fieldPath=`.accessModes`,reason="FieldValueRequired" - // - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2098-L2100 - // - https://releases.k8s.io/v1.31.0/pkg/apis/core/validation/validation.go#L2292-L2294 - // +kubebuilder:validation:XValidation:rule=`has(self.accessModes) && size(self.accessModes) > 0`,message=`missing accessModes` - // - // NOTE(validation): Every PVC must have a positive storage request. NOTE(KEP-4153) - // TODO(k8s-1.28): fieldPath=`.resources.requests.storage`,reason="FieldValueRequired" - // - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2126-L2133 - // - https://releases.k8s.io/v1.31.0/pkg/apis/core/validation/validation.go#L2318-L2325 - // +kubebuilder:validation:XValidation:rule=`has(self.resources) && has(self.resources.requests) && has(self.resources.requests.storage)`,message=`missing storage request` - DataVolumeClaimSpec corev1.PersistentVolumeClaimSpec `json:"dataVolumeClaimSpec"` + // +required + DataVolumeClaimSpec VolumeClaimSpec `json:"dataVolumeClaimSpec"` // Priority class name for the PostgreSQL pod. Changing this value causes // PostgreSQL to restart. @@ -485,7 +496,7 @@ type PostgresInstanceSetSpec struct { // Compute resources of a PostgreSQL container. // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Configuration for instance sidecar containers // +optional @@ -505,20 +516,8 @@ type PostgresInstanceSetSpec struct { // Defines a separate PersistentVolumeClaim for PostgreSQL's write-ahead log. // More info: https://www.postgresql.org/docs/current/wal.html // --- - // +kubebuilder:validation:Optional - // - // NOTE(validation): Every PVC must have at least one accessMode. NOTE(KEP-4153) - // TODO(k8s-1.28): fieldPath=`.accessModes`,reason="FieldValueRequired" - // - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2098-L2100 - // - https://releases.k8s.io/v1.31.0/pkg/apis/core/validation/validation.go#L2292-L2294 - // +kubebuilder:validation:XValidation:rule=`has(self.accessModes) && size(self.accessModes) > 0`,message=`missing accessModes` - // - // NOTE(validation): Every PVC must have a positive storage request. NOTE(KEP-4153) - // TODO(k8s-1.28): fieldPath=`.resources.requests.storage`,reason="FieldValueRequired" - // - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2126-L2133 - // - https://releases.k8s.io/v1.31.0/pkg/apis/core/validation/validation.go#L2318-L2325 - // +kubebuilder:validation:XValidation:rule=`has(self.resources) && has(self.resources.requests) && has(self.resources.requests.storage)`,message=`missing storage request` - WALVolumeClaimSpec *corev1.PersistentVolumeClaimSpec `json:"walVolumeClaimSpec,omitempty"` + // +optional + WALVolumeClaimSpec *VolumeClaimSpec `json:"walVolumeClaimSpec,omitempty"` // The list of tablespaces volumes to mount for this postgrescluster // This field requires enabling TablespaceVolumes feature gate @@ -526,6 +525,24 @@ type PostgresInstanceSetSpec struct { // +listMapKey=name // +optional TablespaceVolumes []TablespaceVolume `json:"tablespaceVolumes,omitempty"` + + Volumes *PostgresVolumesSpec `json:"volumes,omitempty"` +} + +type PostgresVolumesSpec struct { + // Additional pre-existing volumes to add to the pod. + // --- + // +optional + // +listType=map + // +listMapKey=name + // +kubebuilder:validation:MaxItems=10 + Additional []AdditionalVolume `json:"additional,omitempty"` + + // An ephemeral volume for temporary files. + // More info: https://kubernetes.io/docs/concepts/storage/ephemeral-volumes + // --- + // +optional + Temp *VolumeClaimSpec `json:"temp,omitempty"` } type TablespaceVolume struct { @@ -547,20 +564,8 @@ type TablespaceVolume struct { // Defines a PersistentVolumeClaim for a tablespace. // More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes // --- - // +kubebuilder:validation:Required - // - // NOTE(validation): Every PVC must have at least one accessMode. NOTE(KEP-4153) - // TODO(k8s-1.28): fieldPath=`.accessModes`,reason="FieldValueRequired" - // - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2098-L2100 - // - https://releases.k8s.io/v1.31.0/pkg/apis/core/validation/validation.go#L2292-L2294 - // +kubebuilder:validation:XValidation:rule=`has(self.accessModes) && size(self.accessModes) > 0`,message=`missing accessModes` - // - // NOTE(validation): Every PVC must have a positive storage request. NOTE(KEP-4153) - // TODO(k8s-1.28): fieldPath=`.resources.requests.storage`,reason="FieldValueRequired" - // - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2126-L2133 - // - https://releases.k8s.io/v1.31.0/pkg/apis/core/validation/validation.go#L2318-L2325 - // +kubebuilder:validation:XValidation:rule=`has(self.resources) && has(self.resources.requests) && has(self.resources.requests.storage)`,message=`missing storage request` - DataVolumeClaimSpec corev1.PersistentVolumeClaimSpec `json:"dataVolumeClaimSpec"` + // +required + DataVolumeClaimSpec VolumeClaimSpec `json:"dataVolumeClaimSpec"` } // InstanceSidecars defines the configuration for instance sidecar containers @@ -621,7 +626,8 @@ type RegistrationRequirementStatus struct { } type PostgresProxyStatus struct { - PGBouncer PGBouncerPodStatus `json:"pgBouncer,omitempty"` + // +optional + PGBouncer PGBouncerPodStatus `json:"pgBouncer,omitzero"` } // PostgresStandbySpec defines if/how the cluster should be a hot standby. @@ -667,15 +673,14 @@ func (s *UserInterfaceSpec) Default() { type PostgresUserInterfaceStatus struct { // The state of the pgAdmin user interface. - PGAdmin PGAdminPodStatus `json:"pgAdmin,omitempty"` -} - -type PostgresAdditionalConfig struct { - Files []corev1.VolumeProjection `json:"files,omitempty"` + // +optional + PGAdmin PGAdminPodStatus `json:"pgAdmin,omitzero"` } -// +kubebuilder:object:root=true -// +kubebuilder:subresource:status +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:storageversion +//+versionName=v1beta1 // +operator-sdk:csv:customresourcedefinitions:resources={{ConfigMap,v1},{Secret,v1},{Service,v1},{CronJob,v1beta1},{Deployment,v1},{Job,v1},{StatefulSet,v1},{PersistentVolumeClaim,v1}} // PostgresCluster is the Schema for the postgresclusters API @@ -684,14 +689,17 @@ type PostgresCluster struct { // - https://docs.k8s.io/concepts/overview/working-with-objects/names/#dns-subdomain-names // - https://releases.k8s.io/v1.21.0/staging/src/k8s.io/apiextensions-apiserver/pkg/registry/customresource/validator.go#L60 - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` // NOTE(cbandy): Every PostgresCluster needs a Spec, but it is optional here // so ObjectMeta can be managed independently. - Spec PostgresClusterSpec `json:"spec,omitempty"` - Status PostgresClusterStatus `json:"status,omitempty"` + // +optional + Spec PostgresClusterSpec `json:"spec,omitzero"` + // +optional + Status PostgresClusterStatus `json:"status,omitzero"` } // Default implements "sigs.k8s.io/controller-runtime/pkg/webhook.Defaulter" so @@ -712,7 +720,7 @@ func (c *PostgresCluster) Default() { // PostgresClusterList contains a list of PostgresCluster type PostgresClusterList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + metav1.ListMeta `json:"metadata,omitzero"` Items []PostgresCluster `json:"items"` } diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_test.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types_test.go similarity index 88% rename from pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_test.go rename to pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types_test.go index 83396902d0..ba8e74c468 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_test.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/postgrescluster_types_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -10,14 +10,9 @@ import ( "testing" "gotest.tools/v3/assert" - "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/yaml" ) -func TestPostgresClusterWebhooks(t *testing.T) { - var _ webhook.Defaulter = new(PostgresCluster) -} - func TestPostgresClusterDefault(t *testing.T) { t.Run("TypeMeta", func(t *testing.T) { var cluster PostgresCluster @@ -36,13 +31,7 @@ func TestPostgresClusterDefault(t *testing.T) { assert.DeepEqual(t, string(b), strings.TrimSpace(` apiVersion: postgres-operator.crunchydata.com/v1beta1 kind: PostgresCluster -metadata: - creationTimestamp: null spec: - backups: - pgbackrest: - repos: null - config: {} instances: null patroni: leaderLeaseDurationSeconds: 30 @@ -50,13 +39,7 @@ spec: syncPeriodSeconds: 10 port: 5432 postgresVersion: 0 -status: - monitoring: {} - patroni: {} - postgresVersion: 0 - proxy: - pgBouncer: {} - `)+"\n") + `)+"\n") }) t.Run("one instance set", func(t *testing.T) { @@ -69,32 +52,19 @@ status: assert.DeepEqual(t, string(b), strings.TrimSpace(` apiVersion: postgres-operator.crunchydata.com/v1beta1 kind: PostgresCluster -metadata: - creationTimestamp: null spec: - backups: - pgbackrest: - repos: null - config: {} instances: - dataVolumeClaimSpec: resources: {} name: "00" replicas: 1 - resources: {} patroni: leaderLeaseDurationSeconds: 30 port: 8008 syncPeriodSeconds: 10 port: 5432 postgresVersion: 0 -status: - monitoring: {} - patroni: {} - postgresVersion: 0 - proxy: - pgBouncer: {} - `)+"\n") + `)+"\n") }) t.Run("empty proxy", func(t *testing.T) { @@ -116,10 +86,8 @@ status: assert.NilError(t, err) assert.DeepEqual(t, string(b), strings.TrimSpace(` pgBouncer: - config: {} port: 5432 replicas: 1 - resources: {} `)+"\n") }) } @@ -135,7 +103,6 @@ dataVolumeClaimSpec: resources: {} name: "05" replicas: 1 -resources: {} `)+"\n") } diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types.go index 1dc4e3627e..d4ecaba821 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types.go @@ -1,30 +1,171 @@ -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 package v1beta1 import ( + "encoding/json" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kube-openapi/pkg/validation/strfmt" ) +// --- +// https://pkg.go.dev/k8s.io/apimachinery/pkg/util/validation#IsConfigMapKey +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=253 +// +kubebuilder:validation:Pattern=`^[-._a-zA-Z0-9]+$` +// +kubebuilder:validation:XValidation:rule=`self != "." && !self.startsWith("..")`,message=`cannot be "." or start with ".."` +type ConfigDataKey = string + +// --- +// https://docs.k8s.io/concepts/overview/working-with-objects/names#dns-subdomain-names +// https://pkg.go.dev/k8s.io/apimachinery/pkg/util/validation#IsDNS1123Subdomain +// https://pkg.go.dev/k8s.io/apiserver/pkg/cel/library#Format +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=253 +// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` +type DNS1123Subdomain = string + +// --- +// https://docs.k8s.io/concepts/overview/working-with-objects/names#dns-label-names +// https://pkg.go.dev/k8s.io/apimachinery/pkg/util/validation#IsDNS1123Label +// https://pkg.go.dev/k8s.io/apiserver/pkg/cel/library#Format +// +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` +type DNS1123Label = string + +// --- +// Duration represents a string accepted by the Kubernetes API in the "duration" +// [format]. This format extends the "duration" [defined by OpenAPI] by allowing +// some whitespace and more units: +// +// - nanoseconds: ns, nano, nanos +// - microseconds: us, µs, micro, micros +// - milliseconds: ms, milli, millis +// - seconds: s, sec, secs +// - minutes: m, min, mins +// - hours: h, hr, hour, hours +// - days: d, day, days +// - weeks: w, wk, week, weeks +// +// An empty amount is represented as "0" with no unit. +// One day is always 24 hours and one week is always 7 days (168 hours). +// +// +kubebuilder:validation:Format=duration +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:Type=string +// +// During CEL validation, a value of this type is a "google.protobuf.Duration". +// It is safe to pass the value to `duration()` but not necessary. +// +// - https://docs.k8s.io/reference/using-api/cel/#type-system-integration +// - https://github.com/google/cel-spec/blob/-/doc/langdef.md#types-and-conversions +// +// NOTE: When using this type, reject fractional numbers using a Pattern to +// avoid an upstream bug: https://github.com/kubernetes/kube-openapi/issues/523 +// +// [defined by OpenAPI]: https://spec.openapis.org/registry/format/duration.html +// [format]: https://spec.openapis.org/oas/latest.html#data-type-format +type Duration struct { + parsed metav1.Duration + string +} + +// NewDuration creates a duration from the Kubernetes "duration" format in s. +func NewDuration(s string) (*Duration, error) { + td, err := strfmt.ParseDuration(s) + + // The unkeyed fields here helpfully raise warnings from the compiler + // if [metav1.Duration] changes shape in the future. + type unkeyed metav1.Duration + umd := unkeyed{td} + + return &Duration{metav1.Duration(umd), s}, err +} + +// AsDuration returns a copy of d as a [metav1.Duration]. +func (d *Duration) AsDuration() metav1.Duration { + return d.parsed +} + +// MarshalJSON implements [json.Marshaler]. +func (d Duration) MarshalJSON() ([]byte, error) { + if d.parsed.Duration == 0 { + return json.Marshal("0") + } + + return json.Marshal(d.string) +} + +// UnmarshalJSON implements [json.Unmarshaler]. +func (d *Duration) UnmarshalJSON(data []byte) error { + var next *Duration + var str string + + err := json.Unmarshal(data, &str) + if err == nil { + next, err = NewDuration(str) + } + if err == nil { + *d = *next + } + return err +} + +// --- +// NOTE(validation): Every PVC must have at least one accessMode. NOTE(KEP-5073) +// TODO(k8s-1.28): fieldPath=`.accessModes`,reason="FieldValueRequired" +// - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2098-L2100 +// - https://releases.k8s.io/v1.32.0/pkg/apis/core/validation/validation.go#L2303-L2305 +// +kubebuilder:validation:XValidation:rule=`0 < size(self.accessModes)`,message=`missing accessModes` +// +// NOTE(validation): Every PVC must have a positive storage request. NOTE(KEP-5073) +// TODO(k8s-1.28): fieldPath=`.resources.requests.storage`,reason="FieldValueRequired" +// TODO(k8s-1.29): `&& 0 < quantity(self.resources.requests.storage).sign()` +// - https://releases.k8s.io/v1.25.0/pkg/apis/core/validation/validation.go#L2126-L2133 +// - https://releases.k8s.io/v1.32.0/pkg/apis/core/validation/validation.go#L2329-L2336 +// +kubebuilder:validation:XValidation:rule=`has(self.resources.requests.storage)`,message=`missing storage request` +// +// +structType=atomic +type VolumeClaimSpec corev1.PersistentVolumeClaimSpec + +// DeepCopyInto copies the receiver into out. Both must be non-nil. +func (spec *VolumeClaimSpec) DeepCopyInto(out *VolumeClaimSpec) { + (*corev1.PersistentVolumeClaimSpec)(spec).DeepCopyInto((*corev1.PersistentVolumeClaimSpec)(out)) +} + +// AsPersistentVolumeClaimSpec returns a copy of spec as a [corev1.PersistentVolumeClaimSpec]. +func (spec *VolumeClaimSpec) AsPersistentVolumeClaimSpec() corev1.PersistentVolumeClaimSpec { + var out corev1.PersistentVolumeClaimSpec + spec.DeepCopyInto((*VolumeClaimSpec)(&out)) + return out +} + +// --- // SchemalessObject is a map compatible with JSON object. // // Use with the following markers: -// - kubebuilder:pruning:PreserveUnknownFields -// - kubebuilder:validation:Schemaless -// - kubebuilder:validation:Type=object +// - kubebuilder:pruning:PreserveUnknownFields +// - kubebuilder:validation:Schemaless +// - kubebuilder:validation:Type=object +// +// NOTE: PreserveUnknownFields allows arbitrary values within fields of this +// type but also prevents any validation rules from reaching inside; its CEL +// type is "object" or "message" with zero fields: +// https://kubernetes.io/docs/reference/using-api/cel/#type-system-integration type SchemalessObject map[string]any // DeepCopy creates a new SchemalessObject by copying the receiver. -func (in *SchemalessObject) DeepCopy() *SchemalessObject { - if in == nil { - return nil - } - out := new(SchemalessObject) - *out = runtime.DeepCopyJSON(*in) - return out +func (in SchemalessObject) DeepCopy() SchemalessObject { + return runtime.DeepCopyJSON(in) } type ServiceSpec struct { @@ -39,23 +180,47 @@ type ServiceSpec struct { NodePort *int32 `json:"nodePort,omitempty"` // More info: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 // // +optional // +kubebuilder:default=ClusterIP // +kubebuilder:validation:Enum={ClusterIP,NodePort,LoadBalancer} Type string `json:"type"` + // More info: https://kubernetes.io/docs/reference/kubernetes-api/service-resources/service-v1/ + // --- + // +optional + // +kubebuilder:validation:Enum=SingleStack;PreferDualStack;RequireDualStack + IPFamilyPolicy *corev1.IPFamilyPolicy `json:"ipFamilyPolicy,omitempty"` + + // +optional + // +kubebuilder:validation:items:Enum={IPv4,IPv6} + IPFamilies []corev1.IPFamily `json:"ipFamilies,omitempty"` + // More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=10 + // +kubebuilder:validation:Type=string // // +optional // +kubebuilder:validation:Enum={Cluster,Local} - InternalTrafficPolicy *corev1.ServiceInternalTrafficPolicyType `json:"internalTrafficPolicy,omitempty"` + InternalTrafficPolicy *corev1.ServiceInternalTrafficPolicy `json:"internalTrafficPolicy,omitempty"` // More info: https://kubernetes.io/docs/concepts/services-networking/service/#traffic-policies + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=10 + // +kubebuilder:validation:Type=string // // +optional // +kubebuilder:validation:Enum={Cluster,Local} - ExternalTrafficPolicy *corev1.ServiceExternalTrafficPolicyType `json:"externalTrafficPolicy,omitempty"` + ExternalTrafficPolicy *corev1.ServiceExternalTrafficPolicy `json:"externalTrafficPolicy,omitempty"` } // Sidecar defines the configuration of a sidecar container @@ -91,3 +256,51 @@ func (meta *Metadata) GetAnnotationsOrNil() map[string]string { } return meta.Annotations } + +// --- +// Only one applier should be managing each volume definition. +// https://docs.k8s.io/reference/using-api/server-side-apply#merge-strategy +// +structType=atomic +type AdditionalVolume struct { + // Name of an existing PersistentVolumeClaim. + // --- + // https://pkg.go.dev/k8s.io/kubernetes/pkg/apis/core/validation#ValidatePersistentVolumeClaim + // https://pkg.go.dev/k8s.io/kubernetes/pkg/apis/core/validation#ValidatePersistentVolumeName + // + // +required + ClaimName DNS1123Subdomain `json:"claimName"` + + // The names of containers in which to mount this volume. + // The default mounts the volume in *all* containers. An empty list does not mount the volume to any containers. + // --- + // These are matched against [corev1.Container.Name] in a PodSpec, which is a [DNS1123Label]. + // https://pkg.go.dev/k8s.io/kubernetes/pkg/apis/core/validation#ValidatePodSpec + // + // Container names are unique within a Pod, so this list can be, too. + // +listType=set + // + // +kubebuilder:validation:MaxItems=10 + // +optional + Containers []DNS1123Label `json:"containers"` + + // The name of the directory in which to mount this volume. + // Volumes are mounted in containers at `/volumes/{name}`. + // --- + // This also goes into the [corev1.Volume.Name] field, which is a [DNS1123Label]. + // https://pkg.go.dev/k8s.io/kubernetes/pkg/apis/core/validation#ValidatePodSpec + // https://pkg.go.dev/k8s.io/kubernetes/pkg/apis/core/validation#ValidateVolumes + // + // We prepend "volumes-" to avoid collisions with other [corev1.PodSpec.Volumes], + // so the maximum is 8 less than the inherited 63. + // +kubebuilder:validation:MaxLength=55 + // + // +required + Name DNS1123Label `json:"name"` + + // When true, mount the volume read-only, otherwise read-write. Defaults to false. + // --- + // [corev1.VolumeMount.ReadOnly] + // + // +optional + ReadOnly bool `json:"readOnly,omitempty"` +} diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types_test.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types_test.go index 96cd4da073..c4c2fe65f9 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types_test.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/shared_types_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2022 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -7,53 +7,243 @@ package v1beta1 import ( "reflect" "testing" + "time" "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/kube-openapi/pkg/validation/strfmt" "sigs.k8s.io/yaml" ) -func TestSchemalessObjectDeepCopy(t *testing.T) { +func TestDurationAsDuration(t *testing.T) { + t.Parallel() + + v, err := NewDuration("2s") + assert.NilError(t, err) + + // get the value + other := v.AsDuration() + assert.Equal(t, other.Duration, 2*time.Second, + "expected the same value as the original") + + // change the copy + other.Duration = time.Hour + assert.Equal(t, v.AsDuration().Duration, 2*time.Second, + "expected no effect on the original value") +} + +func TestDurationYAML(t *testing.T) { t.Parallel() - var n *SchemalessObject - assert.DeepEqual(t, n, n.DeepCopy()) + t.Run("Zero", func(t *testing.T) { + zero, err := yaml.Marshal(Duration{}) + assert.NilError(t, err) + assert.DeepEqual(t, zero, []byte(`"0"`+"\n")) + + var parsed Duration + assert.NilError(t, yaml.UnmarshalStrict(zero, &parsed)) + assert.Equal(t, parsed.AsDuration().Duration, 0*time.Second) + + // This is what Kubernetes calls when validating the "duration" format. + // - https://releases.k8s.io/v1.32.0/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go#L116 + assert.Assert(t, strfmt.IsDuration("0")) + }) + + t.Run("Small", func(t *testing.T) { + var parsed Duration + assert.NilError(t, yaml.UnmarshalStrict([]byte(`3ns`), &parsed)) + assert.Equal(t, parsed.AsDuration().Duration, 3*time.Nanosecond) + + b, err := yaml.Marshal(parsed) + assert.NilError(t, err) + assert.DeepEqual(t, b, []byte(`3ns`+"\n")) + }) + + t.Run("Large", func(t *testing.T) { + var parsed Duration + assert.NilError(t, yaml.UnmarshalStrict([]byte(`52 weeks`), &parsed)) + assert.Equal(t, parsed.AsDuration().Duration, 364*24*time.Hour) + + b, err := yaml.Marshal(parsed) + assert.NilError(t, err) + assert.DeepEqual(t, b, []byte(`52 weeks`+"\n")) + }) + + t.Run("UnitsIn", func(t *testing.T) { + const Day = 24 * time.Hour + const Week = 7 * Day + + for _, tt := range []struct { + input string + result time.Duration + }{ + // These can be unmarshaled: + {"1 ns", time.Nanosecond}, + {"2 nano", 2 * time.Nanosecond}, + {"3 nanos", 3 * time.Nanosecond}, + {"4 nanosec", 4 * time.Nanosecond}, + {"5 nanosecs", 5 * time.Nanosecond}, + {"6 nanopants", 6 * time.Nanosecond}, + + {"1 us", time.Microsecond}, + {"2 µs", 2 * time.Microsecond}, + {"3 micro", 3 * time.Microsecond}, + {"4 micros", 4 * time.Microsecond}, + {"5 micrometer", 5 * time.Microsecond}, + + {"1 ms", time.Millisecond}, + {"2 milli", 2 * time.Millisecond}, + {"3 millis", 3 * time.Millisecond}, + {"4 millisec", 4 * time.Millisecond}, + {"5 millisecs", 5 * time.Millisecond}, + {"6 millipede", 6 * time.Millisecond}, + + {"1s", time.Second}, + {"2 sec", 2 * time.Second}, + {"3 secs", 3 * time.Second}, + {"4 seconds", 4 * time.Second}, + {"5 security", 5 * time.Second}, + + {"1m", time.Minute}, + {"2 min", 2 * time.Minute}, + {"3 mins", 3 * time.Minute}, + {"4 minutia", 4 * time.Minute}, + {"5 mininture", 5 * time.Minute}, + + {"1h", time.Hour}, + {"2 hr", 2 * time.Hour}, + {"3 hour", 3 * time.Hour}, + {"4 hours", 4 * time.Hour}, + {"5 hourglass", 5 * time.Hour}, + + {"1d", Day}, + {"2 day", 2 * Day}, + {"3 days", 3 * Day}, + {"4 dayrock", 4 * Day}, + + {"1w", Week}, + {"2 wk", 2 * Week}, + {"3 week", 3 * Week}, + {"4 weeks", 4 * Week}, + {"5 weekpasta", 5 * Week}, + + // ISO 8601 / RFC 33339 + {"PT2D9H", (2 * Day) + 9*time.Hour}, + } { + var parsed Duration + assert.NilError(t, yaml.UnmarshalStrict([]byte(tt.input), &parsed)) + assert.Equal(t, parsed.AsDuration().Duration, tt.result) + + // This is what Kubernetes calls when validating the "duration" format. + // - https://releases.k8s.io/v1.32.0/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go#L116 + assert.Assert(t, strfmt.IsDuration(tt.input)) + } + + for _, tt := range []string{ + // These cannot be unmarshaled: + "1 nss", + "2 uss", + "3 usec", + "4 usecs", + "5 µsec", + "6 mss", + "7 hs", + "8 hrs", + "9 ds", + "10 ws", + "11 wks", + } { + assert.ErrorContains(t, + yaml.UnmarshalStrict([]byte(tt), new(Duration)), "unable to parse") + + // This is what Kubernetes calls when validating the "duration" format. + // - https://releases.k8s.io/v1.32.0/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/validation/validation.go#L116 + assert.Assert(t, !strfmt.IsDuration(tt)) + } + }) + + t.Run("DoNotUsePartialAmounts", func(t *testing.T) { + var parsed Duration + assert.NilError(t, yaml.UnmarshalStrict([]byte(`1.5 hours`), &parsed)) + + expected, err := time.ParseDuration(`1.5h`) + assert.NilError(t, err) + + // The parsed value is *not* the expected amount. + assert.Assert(t, parsed.AsDuration().Duration != expected, + "expected https://github.com/kubernetes/kube-openapi/issues/523") + }) +} + +func TestSchemalessObjectDeepCopy(t *testing.T) { + t.Parallel() var z SchemalessObject - assert.DeepEqual(t, z, *z.DeepCopy()) + assert.DeepEqual(t, z, z.DeepCopy()) var one SchemalessObject - assert.NilError(t, yaml.Unmarshal( + assert.NilError(t, yaml.UnmarshalStrict( []byte(`{ str: value, num: 1, arr: [a, 2, true] }`), &one, )) // reflect and go-cmp agree the original and copy are equivalent. - same := *one.DeepCopy() + same := one.DeepCopy() assert.DeepEqual(t, one, same) assert.Assert(t, reflect.DeepEqual(one, same)) // Changes to the copy do not affect the original. { - change := *one.DeepCopy() + change := one.DeepCopy() change["str"] = "banana" assert.Assert(t, reflect.DeepEqual(one, same)) assert.Assert(t, !reflect.DeepEqual(one, change)) } { - change := *one.DeepCopy() + change := one.DeepCopy() change["num"] = 99 assert.Assert(t, reflect.DeepEqual(one, same)) assert.Assert(t, !reflect.DeepEqual(one, change)) } { - change := *one.DeepCopy() + change := one.DeepCopy() change["arr"].([]any)[0] = "rock" assert.Assert(t, reflect.DeepEqual(one, same)) assert.Assert(t, !reflect.DeepEqual(one, change)) } { - change := *one.DeepCopy() + change := one.DeepCopy() change["arr"] = append(change["arr"].([]any), "more") assert.Assert(t, reflect.DeepEqual(one, same)) assert.Assert(t, !reflect.DeepEqual(one, change)) } } + +func TestVolumeClaimSpecYAML(t *testing.T) { + t.Parallel() + + var zero VolumeClaimSpec + out, err := yaml.Marshal(zero) + assert.NilError(t, err) + assert.DeepEqual(t, string(out), "resources: {}\n") + + var parsed VolumeClaimSpec + assert.NilError(t, yaml.Unmarshal([]byte(`{ + accessModes: [ReadWriteMany], + resources: { requests: { storage: 1Gi } }, + storageClassName: zork, + }`), &parsed)) + + zork := "zork" + assert.DeepEqual(t, parsed, VolumeClaimSpec{ + StorageClassName: &zork, + AccessModes: []corev1.PersistentVolumeAccessMode{ + corev1.ReadWriteMany, + }, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: resource.MustParse("1Gi"), + }, + }, + }) +} diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go index 4fbc90a3b9..aa5ac90b46 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/standalone_pgadmin_types.go @@ -1,4 +1,4 @@ -// Copyright 2023 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2023 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -19,9 +19,9 @@ type StandalonePGAdminConfiguration struct { // A Secret containing the value for the CONFIG_DATABASE_URI setting. // More info: https://www.pgadmin.org/docs/pgadmin4/latest/external_database.html // +optional - ConfigDatabaseURI *corev1.SecretKeySelector `json:"configDatabaseURI,omitempty"` + ConfigDatabaseURI *OptionalSecretKeyRef `json:"configDatabaseURI,omitempty"` - // Settings for the gunicorn server. + // Settings for the Gunicorn server. // More info: https://docs.gunicorn.org/en/latest/settings.html // +optional // +kubebuilder:pruning:PreserveUnknownFields @@ -32,16 +32,51 @@ type StandalonePGAdminConfiguration struct { // A Secret containing the value for the LDAP_BIND_PASSWORD setting. // More info: https://www.pgadmin.org/docs/pgadmin4/latest/ldap.html // +optional - LDAPBindPassword *corev1.SecretKeySelector `json:"ldapBindPassword,omitempty"` + LDAPBindPassword *OptionalSecretKeyRef `json:"ldapBindPassword,omitempty"` // Settings for the pgAdmin server process. Keys should be uppercase and // values must be constants. // More info: https://www.pgadmin.org/docs/pgadmin4/latest/config_py.html - // +optional + // --- // +kubebuilder:pruning:PreserveUnknownFields // +kubebuilder:validation:Schemaless // +kubebuilder:validation:Type=object + // + // +mapType=granular + // +optional Settings SchemalessObject `json:"settings,omitempty"` + + // Secrets for the `OAUTH2_CONFIG` setting. If there are `OAUTH2_CONFIG` values + // in the settings field, they will be combined with the values loaded here. + // More info: https://www.pgadmin.org/docs/pgadmin4/latest/oauth2.html + // --- + // The controller expects this number to be no more than two digits. + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=10 + // + // +listType=map + // +listMapKey=name + // +optional + OAuthConfigurations []PGAdminOAuthConfig `json:"oauthConfigurations,omitempty"` +} + +// +structType=atomic +type PGAdminOAuthConfig struct { + // The OAUTH2_NAME of this configuration. + // --- + // This goes into a filename, so let's keep it short and simple. + // The Secret is allowed to contain OAUTH2_NAME and deviate from this. + // +kubebuilder:validation:Pattern=`^[A-Za-z0-9]+$` + // + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=20 + // +required + Name string `json:"name"` + + // A Secret containing the settings of one OAuth2 provider as a JSON object. + // --- + // +required + Secret SecretKeyRef `json:"secret"` } // PGAdminSpec defines the desired state of PGAdmin @@ -54,12 +89,13 @@ type PGAdminSpec struct { // values will be loaded without validation. Be careful, as // you may put pgAdmin into an unusable state. // +optional - Config StandalonePGAdminConfiguration `json:"config,omitempty"` + Config StandalonePGAdminConfiguration `json:"config,omitzero"` // Defines a PersistentVolumeClaim for pgAdmin data. // More info: https://kubernetes.io/docs/concepts/storage/persistent-volumes - // +kubebuilder:validation:Required - DataVolumeClaimSpec corev1.PersistentVolumeClaimSpec `json:"dataVolumeClaimSpec"` + // --- + // +required + DataVolumeClaimSpec VolumeClaimSpec `json:"dataVolumeClaimSpec"` // The image name to use for pgAdmin instance. // +optional @@ -68,6 +104,12 @@ type PGAdminSpec struct { // ImagePullPolicy is used to determine when Kubernetes will attempt to // pull (download) container images. // More info: https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 + // +kubebuilder:validation:Type=string + // // +kubebuilder:validation:Enum={Always,Never,IfNotPresent} // +optional ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"` @@ -78,9 +120,14 @@ type PGAdminSpec struct { // +optional ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"` + // Configuration for the OpenTelemetry collector container used to collect + // logs and metrics. + // +optional + Instrumentation *InstrumentationSpec `json:"instrumentation,omitempty"` + // Resource requirements for the PGAdmin container. // +optional - Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitzero"` // Scheduling constraints of the PGAdmin pod. // More info: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node @@ -136,7 +183,7 @@ type ServerGroup struct { // PostgresClusterSelector selects clusters to dynamically add to pgAdmin by matching labels. // An empty selector like `{}` will select ALL clusters in the namespace. // +kubebuilder:validation:Optional - PostgresClusterSelector metav1.LabelSelector `json:"postgresClusterSelector,omitempty"` + PostgresClusterSelector metav1.LabelSelector `json:"postgresClusterSelector,omitzero"` } type PGAdminUser struct { @@ -146,6 +193,11 @@ type PGAdminUser struct { // Role determines whether the user has admin privileges or not. // Defaults to User. Valid options are Administrator and User. + // --- + // Kubernetes assumes the evaluation cost of an enum value is very large. + // TODO(k8s-1.29): Drop MaxLength after Kubernetes 1.29; https://issue.k8s.io/119511 + // +kubebuilder:validation:MaxLength=15 + // // +kubebuilder:validation:Enum={Administrator,User} // +optional Role string `json:"role,omitempty"` @@ -183,14 +235,19 @@ type PGAdminStatus struct { //+kubebuilder:object:root=true //+kubebuilder:subresource:status +//+kubebuilder:storageversion +//+versionName=v1beta1 // PGAdmin is the Schema for the PGAdmin API type PGAdmin struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` + metav1.TypeMeta `json:",inline"` + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` - Spec PGAdminSpec `json:"spec,omitempty"` - Status PGAdminStatus `json:"status,omitempty"` + // +optional + Spec PGAdminSpec `json:"spec,omitzero"` + // +optional + Status PGAdminStatus `json:"status,omitzero"` } // Default implements "sigs.k8s.io/controller-runtime/pkg/webhook.Defaulter" so @@ -205,12 +262,18 @@ func (p *PGAdmin) Default() { } } +func NewPGAdmin() *PGAdmin { + p := &PGAdmin{} + p.Default() + return p +} + //+kubebuilder:object:root=true // PGAdminList contains a list of PGAdmin type PGAdminList struct { metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` + metav1.ListMeta `json:"metadata,omitzero"` Items []PGAdmin `json:"items"` } diff --git a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go index fa32069d0f..dbb22d8f38 100644 --- a/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/postgres-operator.crunchydata.com/v1beta1/zz_generated.deepcopy.go @@ -1,6 +1,6 @@ //go:build !ignore_autogenerated -// Copyright 2021 - 2024 Crunchy Data Solutions, Inc. +// Copyright 2021 - 2025 Crunchy Data Solutions, Inc. // // SPDX-License-Identifier: Apache-2.0 @@ -18,9 +18,9 @@ import ( // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *APIResponses) DeepCopyInto(out *APIResponses) { *out = *in - in.Cluster.DeepCopyInto(&out.Cluster) - in.Status.DeepCopyInto(&out.Status) - in.Upgrade.DeepCopyInto(&out.Upgrade) + out.Cluster = in.Cluster.DeepCopy() + out.Status = in.Status.DeepCopy() + out.Upgrade = in.Upgrade.DeepCopy() } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIResponses. @@ -33,6 +33,26 @@ func (in *APIResponses) DeepCopy() *APIResponses { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AdditionalVolume) DeepCopyInto(out *AdditionalVolume) { + *out = *in + if in.Containers != nil { + in, out := &in.Containers, &out.Containers + *out = make([]DNS1123Label, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AdditionalVolume. +func (in *AdditionalVolume) DeepCopy() *AdditionalVolume { + if in == nil { + return nil + } + out := new(AdditionalVolume) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BackupJobs) DeepCopyInto(out *BackupJobs) { *out = *in @@ -59,6 +79,11 @@ func (in *BackupJobs) DeepCopyInto(out *BackupJobs) { *out = new(int32) **out = **in } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(PGBackRestVolumesSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackupJobs. @@ -118,6 +143,21 @@ func (in *ClusterUpgrade) DeepCopy() *ClusterUpgrade { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapKeyRef) DeepCopyInto(out *ConfigMapKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapKeyRef. +func (in *ConfigMapKeyRef) DeepCopy() *ConfigMapKeyRef { + if in == nil { + return nil + } + out := new(ConfigMapKeyRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CrunchyBridgeCluster) DeepCopyInto(out *CrunchyBridgeCluster) { *out = *in @@ -363,6 +403,22 @@ func (in *DatabaseInitSQL) DeepCopy() *DatabaseInitSQL { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Duration) DeepCopyInto(out *Duration) { + *out = *in + out.parsed = in.parsed +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Duration. +func (in *Duration) DeepCopy() *Duration { + if in == nil { + return nil + } + out := new(Duration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExporterSpec) DeepCopyInto(out *ExporterSpec) { *out = *in @@ -411,6 +467,187 @@ func (in *InstanceSidecars) DeepCopy() *InstanceSidecars { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstrumentationConfigSpec) DeepCopyInto(out *InstrumentationConfigSpec) { + *out = *in + if in.Detectors != nil { + in, out := &in.Detectors, &out.Detectors + *out = make([]OpenTelemetryResourceDetector, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.Exporters = in.Exporters.DeepCopy() + if in.Files != nil { + in, out := &in.Files, &out.Files + *out = make([]corev1.VolumeProjection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.EnvironmentVariables != nil { + in, out := &in.EnvironmentVariables, &out.EnvironmentVariables + *out = make([]corev1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstrumentationConfigSpec. +func (in *InstrumentationConfigSpec) DeepCopy() *InstrumentationConfigSpec { + if in == nil { + return nil + } + out := new(InstrumentationConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstrumentationCustomQueries) DeepCopyInto(out *InstrumentationCustomQueries) { + *out = *in + in.Queries.DeepCopyInto(&out.Queries) + if in.CollectionInterval != nil { + in, out := &in.CollectionInterval, &out.CollectionInterval + *out = new(Duration) + **out = **in + } + if in.Databases != nil { + in, out := &in.Databases, &out.Databases + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstrumentationCustomQueries. +func (in *InstrumentationCustomQueries) DeepCopy() *InstrumentationCustomQueries { + if in == nil { + return nil + } + out := new(InstrumentationCustomQueries) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstrumentationCustomQueriesSpec) DeepCopyInto(out *InstrumentationCustomQueriesSpec) { + *out = *in + if in.Add != nil { + in, out := &in.Add, &out.Add + *out = make([]InstrumentationCustomQueries, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Remove != nil { + in, out := &in.Remove, &out.Remove + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstrumentationCustomQueriesSpec. +func (in *InstrumentationCustomQueriesSpec) DeepCopy() *InstrumentationCustomQueriesSpec { + if in == nil { + return nil + } + out := new(InstrumentationCustomQueriesSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstrumentationLogsSpec) DeepCopyInto(out *InstrumentationLogsSpec) { + *out = *in + if in.Batches != nil { + in, out := &in.Batches, &out.Batches + *out = new(OpenTelemetryLogsBatchSpec) + (*in).DeepCopyInto(*out) + } + if in.Exporters != nil { + in, out := &in.Exporters, &out.Exporters + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RetentionPeriod != nil { + in, out := &in.RetentionPeriod, &out.RetentionPeriod + *out = new(Duration) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstrumentationLogsSpec. +func (in *InstrumentationLogsSpec) DeepCopy() *InstrumentationLogsSpec { + if in == nil { + return nil + } + out := new(InstrumentationLogsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstrumentationMetricsSpec) DeepCopyInto(out *InstrumentationMetricsSpec) { + *out = *in + if in.CustomQueries != nil { + in, out := &in.CustomQueries, &out.CustomQueries + *out = new(InstrumentationCustomQueriesSpec) + (*in).DeepCopyInto(*out) + } + if in.Exporters != nil { + in, out := &in.Exporters, &out.Exporters + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.PerDBMetricTargets != nil { + in, out := &in.PerDBMetricTargets, &out.PerDBMetricTargets + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstrumentationMetricsSpec. +func (in *InstrumentationMetricsSpec) DeepCopy() *InstrumentationMetricsSpec { + if in == nil { + return nil + } + out := new(InstrumentationMetricsSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InstrumentationSpec) DeepCopyInto(out *InstrumentationSpec) { + *out = *in + in.Resources.DeepCopyInto(&out.Resources) + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(InstrumentationConfigSpec) + (*in).DeepCopyInto(*out) + } + if in.Logs != nil { + in, out := &in.Logs, &out.Logs + *out = new(InstrumentationLogsSpec) + (*in).DeepCopyInto(*out) + } + if in.Metrics != nil { + in, out := &in.Metrics, &out.Metrics + *out = new(InstrumentationMetricsSpec) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstrumentationSpec. +func (in *InstrumentationSpec) DeepCopy() *InstrumentationSpec { + if in == nil { + return nil + } + out := new(InstrumentationSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Metadata) DeepCopyInto(out *Metadata) { *out = *in @@ -475,6 +712,100 @@ func (in *MonitoringStatus) DeepCopy() *MonitoringStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenTelemetryLogsBatchSpec) DeepCopyInto(out *OpenTelemetryLogsBatchSpec) { + *out = *in + if in.MaxDelay != nil { + in, out := &in.MaxDelay, &out.MaxDelay + *out = new(Duration) + **out = **in + } + if in.MaxRecords != nil { + in, out := &in.MaxRecords, &out.MaxRecords + *out = new(int32) + **out = **in + } + if in.MinRecords != nil { + in, out := &in.MinRecords, &out.MinRecords + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenTelemetryLogsBatchSpec. +func (in *OpenTelemetryLogsBatchSpec) DeepCopy() *OpenTelemetryLogsBatchSpec { + if in == nil { + return nil + } + out := new(OpenTelemetryLogsBatchSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenTelemetryResourceDetector) DeepCopyInto(out *OpenTelemetryResourceDetector) { + *out = *in + if in.Attributes != nil { + in, out := &in.Attributes, &out.Attributes + *out = make(map[string]bool, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenTelemetryResourceDetector. +func (in *OpenTelemetryResourceDetector) DeepCopy() *OpenTelemetryResourceDetector { + if in == nil { + return nil + } + out := new(OpenTelemetryResourceDetector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OptionalConfigMapKeyRef) DeepCopyInto(out *OptionalConfigMapKeyRef) { + *out = *in + in.ConfigMapKeyRef.DeepCopyInto(&out.ConfigMapKeyRef) + if in.Optional != nil { + in, out := &in.Optional, &out.Optional + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptionalConfigMapKeyRef. +func (in *OptionalConfigMapKeyRef) DeepCopy() *OptionalConfigMapKeyRef { + if in == nil { + return nil + } + out := new(OptionalConfigMapKeyRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OptionalSecretKeyRef) DeepCopyInto(out *OptionalSecretKeyRef) { + *out = *in + in.SecretKeyRef.DeepCopyInto(&out.SecretKeyRef) + if in.Optional != nil { + in, out := &in.Optional, &out.Optional + *out = new(bool) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OptionalSecretKeyRef. +func (in *OptionalSecretKeyRef) DeepCopy() *OptionalSecretKeyRef { + if in == nil { + return nil + } + out := new(OptionalSecretKeyRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PGAdmin) DeepCopyInto(out *PGAdmin) { *out = *in @@ -514,10 +845,10 @@ func (in *PGAdminConfiguration) DeepCopyInto(out *PGAdminConfiguration) { } if in.LDAPBindPassword != nil { in, out := &in.LDAPBindPassword, &out.LDAPBindPassword - *out = new(corev1.SecretKeySelector) + *out = new(OptionalSecretKeyRef) (*in).DeepCopyInto(*out) } - in.Settings.DeepCopyInto(&out.Settings) + out.Settings = in.Settings.DeepCopy() } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGAdminConfiguration. @@ -562,6 +893,22 @@ func (in *PGAdminList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PGAdminOAuthConfig) DeepCopyInto(out *PGAdminOAuthConfig) { + *out = *in + in.Secret.DeepCopyInto(&out.Secret) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGAdminOAuthConfig. +func (in *PGAdminOAuthConfig) DeepCopy() *PGAdminOAuthConfig { + if in == nil { + return nil + } + out := new(PGAdminOAuthConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PGAdminPodSpec) DeepCopyInto(out *PGAdminPodSpec) { *out = *in @@ -654,6 +1001,11 @@ func (in *PGAdminSpec) DeepCopyInto(out *PGAdminSpec) { *out = make([]corev1.LocalObjectReference, len(*in)) copy(*out, *in) } + if in.Instrumentation != nil { + in, out := &in.Instrumentation, &out.Instrumentation + *out = new(InstrumentationSpec) + (*in).DeepCopyInto(*out) + } in.Resources.DeepCopyInto(&out.Resources) if in.Affinity != nil { in, out := &in.Affinity, &out.Affinity @@ -877,6 +1229,11 @@ func (in *PGBackRestDataSource) DeepCopyInto(out *PGBackRestDataSource) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(PGBackRestVolumesSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGBackRestDataSource. @@ -1010,6 +1367,11 @@ func (in *PGBackRestRepoHost) DeepCopyInto(out *PGBackRestRepoHost) { *out = new(corev1.SecretProjection) (*in).DeepCopyInto(*out) } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(PGBackRestVolumesSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGBackRestRepoHost. @@ -1137,6 +1499,28 @@ func (in *PGBackRestStatus) DeepCopy() *PGBackRestStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PGBackRestVolumesSpec) DeepCopyInto(out *PGBackRestVolumesSpec) { + *out = *in + if in.Additional != nil { + in, out := &in.Additional, &out.Additional + *out = make([]AdditionalVolume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGBackRestVolumesSpec. +func (in *PGBackRestVolumesSpec) DeepCopy() *PGBackRestVolumesSpec { + if in == nil { + return nil + } + out := new(PGBackRestVolumesSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PGBouncerConfiguration) DeepCopyInto(out *PGBouncerConfiguration) { *out = *in @@ -1251,6 +1635,11 @@ func (in *PGBouncerPodSpec) DeepCopyInto(out *PGBouncerPodSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(PGBouncerVolumesSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGBouncerPodSpec. @@ -1298,6 +1687,28 @@ func (in *PGBouncerSidecars) DeepCopy() *PGBouncerSidecars { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PGBouncerVolumesSpec) DeepCopyInto(out *PGBouncerVolumesSpec) { + *out = *in + if in.Additional != nil { + in, out := &in.Additional, &out.Additional + *out = make([]AdditionalVolume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGBouncerVolumesSpec. +func (in *PGBouncerVolumesSpec) DeepCopy() *PGBouncerVolumesSpec { + if in == nil { + return nil + } + out := new(PGBouncerVolumesSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PGMonitorSpec) DeepCopyInto(out *PGMonitorSpec) { *out = *in @@ -1377,6 +1788,21 @@ func (in *PGUpgradeList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PGUpgradeSettings) DeepCopyInto(out *PGUpgradeSettings) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGUpgradeSettings. +func (in *PGUpgradeSettings) DeepCopy() *PGUpgradeSettings { + if in == nil { + return nil + } + out := new(PGUpgradeSettings) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PGUpgradeSpec) DeepCopyInto(out *PGUpgradeSpec) { *out = *in @@ -1413,6 +1839,7 @@ func (in *PGUpgradeSpec) DeepCopyInto(out *PGUpgradeSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + out.PGUpgradeSettings = in.PGUpgradeSettings } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PGUpgradeSpec. @@ -1447,15 +1874,45 @@ func (in *PGUpgradeStatus) DeepCopy() *PGUpgradeStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PatroniLogConfig) DeepCopyInto(out *PatroniLogConfig) { + *out = *in + if in.StorageLimit != nil { + in, out := &in.StorageLimit, &out.StorageLimit + x := (*in).DeepCopy() + *out = &x + } + if in.Level != nil { + in, out := &in.Level, &out.Level + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PatroniLogConfig. +func (in *PatroniLogConfig) DeepCopy() *PatroniLogConfig { + if in == nil { + return nil + } + out := new(PatroniLogConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PatroniSpec) DeepCopyInto(out *PatroniSpec) { *out = *in - in.DynamicConfiguration.DeepCopyInto(&out.DynamicConfiguration) + out.DynamicConfiguration = in.DynamicConfiguration.DeepCopy() if in.LeaderLeaseDurationSeconds != nil { in, out := &in.LeaderLeaseDurationSeconds, &out.LeaderLeaseDurationSeconds *out = new(int32) **out = **in } + if in.Logging != nil { + in, out := &in.Logging, &out.Logging + *out = new(PatroniLogConfig) + (*in).DeepCopyInto(*out) + } if in.Port != nil { in, out := &in.Port, &out.Port *out = new(int32) @@ -1529,23 +1986,23 @@ func (in *PatroniSwitchover) DeepCopy() *PatroniSwitchover { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *PostgresAdditionalConfig) DeepCopyInto(out *PostgresAdditionalConfig) { +func (in *PostgresAuthenticationSpec) DeepCopyInto(out *PostgresAuthenticationSpec) { *out = *in - if in.Files != nil { - in, out := &in.Files, &out.Files - *out = make([]corev1.VolumeProjection, len(*in)) + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]PostgresHBARuleSpec, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresAdditionalConfig. -func (in *PostgresAdditionalConfig) DeepCopy() *PostgresAdditionalConfig { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresAuthenticationSpec. +func (in *PostgresAuthenticationSpec) DeepCopy() *PostgresAuthenticationSpec { if in == nil { return nil } - out := new(PostgresAdditionalConfig) + out := new(PostgresAuthenticationSpec) in.DeepCopyInto(out) return out } @@ -1603,6 +2060,11 @@ func (in *PostgresClusterDataSource) DeepCopyInto(out *PostgresClusterDataSource (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(PGBackRestVolumesSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresClusterDataSource. @@ -1660,7 +2122,17 @@ func (in *PostgresClusterSpec) DeepCopyInto(out *PostgresClusterSpec) { *out = new(DataSource) (*in).DeepCopyInto(*out) } + if in.Authentication != nil { + in, out := &in.Authentication, &out.Authentication + *out = new(PostgresAuthenticationSpec) + (*in).DeepCopyInto(*out) + } in.Backups.DeepCopyInto(&out.Backups) + if in.Config != nil { + in, out := &in.Config, &out.Config + *out = new(PostgresConfigSpec) + (*in).DeepCopyInto(*out) + } if in.CustomTLSSecret != nil { in, out := &in.CustomTLSSecret, &out.CustomTLSSecret *out = new(corev1.SecretProjection) @@ -1693,6 +2165,11 @@ func (in *PostgresClusterSpec) DeepCopyInto(out *PostgresClusterSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Instrumentation != nil { + in, out := &in.Instrumentation, &out.Instrumentation + *out = new(InstrumentationSpec) + (*in).DeepCopyInto(*out) + } if in.OpenShift != nil { in, out := &in.OpenShift, &out.OpenShift *out = new(bool) @@ -1760,7 +2237,6 @@ func (in *PostgresClusterSpec) DeepCopyInto(out *PostgresClusterSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - in.Config.DeepCopyInto(&out.Config) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresClusterSpec. @@ -1825,6 +2301,83 @@ func (in *PostgresClusterStatus) DeepCopy() *PostgresClusterStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresConfigSpec) DeepCopyInto(out *PostgresConfigSpec) { + *out = *in + if in.Files != nil { + in, out := &in.Files, &out.Files + *out = make([]corev1.VolumeProjection, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Parameters != nil { + in, out := &in.Parameters, &out.Parameters + *out = make(map[string]intstr.IntOrString, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresConfigSpec. +func (in *PostgresConfigSpec) DeepCopy() *PostgresConfigSpec { + if in == nil { + return nil + } + out := new(PostgresConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresHBARule) DeepCopyInto(out *PostgresHBARule) { + *out = *in + if in.Databases != nil { + in, out := &in.Databases, &out.Databases + *out = make([]PostgresIdentifier, len(*in)) + copy(*out, *in) + } + if in.Options != nil { + in, out := &in.Options, &out.Options + *out = make(map[string]intstr.IntOrString, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.Users != nil { + in, out := &in.Users, &out.Users + *out = make([]PostgresIdentifier, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresHBARule. +func (in *PostgresHBARule) DeepCopy() *PostgresHBARule { + if in == nil { + return nil + } + out := new(PostgresHBARule) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresHBARuleSpec) DeepCopyInto(out *PostgresHBARuleSpec) { + *out = *in + in.PostgresHBARule.DeepCopyInto(&out.PostgresHBARule) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresHBARuleSpec. +func (in *PostgresHBARuleSpec) DeepCopy() *PostgresHBARuleSpec { + if in == nil { + return nil + } + out := new(PostgresHBARuleSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PostgresInstanceSetSpec) DeepCopyInto(out *PostgresInstanceSetSpec) { *out = *in @@ -1883,8 +2436,7 @@ func (in *PostgresInstanceSetSpec) DeepCopyInto(out *PostgresInstanceSetSpec) { } if in.WALVolumeClaimSpec != nil { in, out := &in.WALVolumeClaimSpec, &out.WALVolumeClaimSpec - *out = new(corev1.PersistentVolumeClaimSpec) - (*in).DeepCopyInto(*out) + *out = (*in).DeepCopy() } if in.TablespaceVolumes != nil { in, out := &in.TablespaceVolumes, &out.TablespaceVolumes @@ -1893,6 +2445,11 @@ func (in *PostgresInstanceSetSpec) DeepCopyInto(out *PostgresInstanceSetSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Volumes != nil { + in, out := &in.Volumes, &out.Volumes + *out = new(PostgresVolumesSpec) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresInstanceSetSpec. @@ -2039,6 +2596,32 @@ func (in *PostgresUserSpec) DeepCopy() *PostgresUserSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PostgresVolumesSpec) DeepCopyInto(out *PostgresVolumesSpec) { + *out = *in + if in.Additional != nil { + in, out := &in.Additional, &out.Additional + *out = make([]AdditionalVolume, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Temp != nil { + in, out := &in.Temp, &out.Temp + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PostgresVolumesSpec. +func (in *PostgresVolumesSpec) DeepCopy() *PostgresVolumesSpec { + if in == nil { + return nil + } + out := new(PostgresVolumesSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RegistrationRequirementStatus) DeepCopyInto(out *RegistrationRequirementStatus) { *out = *in @@ -2150,9 +2733,23 @@ func (in *RepoStatus) DeepCopy() *RepoStatus { func (in SchemalessObject) DeepCopyInto(out *SchemalessObject) { { in := &in - clone := in.DeepCopy() - *out = *clone + *out = in.DeepCopy() + } +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. +func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { + if in == nil { + return nil } + out := new(SecretKeyRef) + in.DeepCopyInto(out) + return out } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. @@ -2184,6 +2781,16 @@ func (in *ServiceSpec) DeepCopyInto(out *ServiceSpec) { *out = new(int32) **out = **in } + if in.IPFamilyPolicy != nil { + in, out := &in.IPFamilyPolicy, &out.IPFamilyPolicy + *out = new(corev1.IPFamilyPolicy) + **out = **in + } + if in.IPFamilies != nil { + in, out := &in.IPFamilies, &out.IPFamilies + *out = make([]corev1.IPFamily, len(*in)) + copy(*out, *in) + } if in.InternalTrafficPolicy != nil { in, out := &in.InternalTrafficPolicy, &out.InternalTrafficPolicy *out = new(corev1.ServiceInternalTrafficPolicy) @@ -2238,16 +2845,23 @@ func (in *StandalonePGAdminConfiguration) DeepCopyInto(out *StandalonePGAdminCon } if in.ConfigDatabaseURI != nil { in, out := &in.ConfigDatabaseURI, &out.ConfigDatabaseURI - *out = new(corev1.SecretKeySelector) + *out = new(OptionalSecretKeyRef) (*in).DeepCopyInto(*out) } - in.Gunicorn.DeepCopyInto(&out.Gunicorn) + out.Gunicorn = in.Gunicorn.DeepCopy() if in.LDAPBindPassword != nil { in, out := &in.LDAPBindPassword, &out.LDAPBindPassword - *out = new(corev1.SecretKeySelector) + *out = new(OptionalSecretKeyRef) (*in).DeepCopyInto(*out) } - in.Settings.DeepCopyInto(&out.Settings) + out.Settings = in.Settings.DeepCopy() + if in.OAuthConfigurations != nil { + in, out := &in.OAuthConfigurations, &out.OAuthConfigurations + *out = make([]PGAdminOAuthConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StandalonePGAdminConfiguration. @@ -2311,6 +2925,16 @@ func (in *UserInterfaceSpec) DeepCopy() *UserInterfaceSpec { return out } +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolumeClaimSpec. +func (in *VolumeClaimSpec) DeepCopy() *VolumeClaimSpec { + if in == nil { + return nil + } + out := new(VolumeClaimSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VolumeSnapshots) DeepCopyInto(out *VolumeSnapshots) { *out = *in diff --git a/testing/chainsaw/e2e/config.yaml b/testing/chainsaw/e2e/config.yaml new file mode 100644 index 0000000000..caa43a9a0c --- /dev/null +++ b/testing/chainsaw/e2e/config.yaml @@ -0,0 +1,12 @@ +apiVersion: chainsaw.kyverno.io/v1alpha2 +kind: Configuration +metadata: + name: end-to-end +spec: + namespace: + template: + metadata: + labels: { postgres-operator-test: chainsaw } + timeouts: + assert: 3m + cleanup: 3m diff --git a/testing/chainsaw/e2e/pgbackrest-restore/README.md b/testing/chainsaw/e2e/pgbackrest-restore/README.md new file mode 100644 index 0000000000..ff9c32b79a --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/README.md @@ -0,0 +1,5 @@ +# pgbackrest-restore + +This [chainsaw](https://github.com/kyverno/chainsaw) suite tests that CPK can clone and restore through pgBackRest backups. + +This md page is meant as a placeholder for further documentation as necessary of this particular test. diff --git a/testing/chainsaw/e2e/pgbackrest-restore/chainsaw-test.yaml b/testing/chainsaw/e2e/pgbackrest-restore/chainsaw-test.yaml new file mode 100644 index 0000000000..18a19553bb --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/chainsaw-test.yaml @@ -0,0 +1,206 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: Test +metadata: + name: pgbackrest-restore + labels: + pgbackrest: '' +spec: + failFast: true + bindings: + - name: postgres + value: + version: (to_number(as_string($values.versions.postgres))) + + - name: psql + value: + image: ($values.images.psql) + connect: { name: PGCONNECT_TIMEOUT, value: '5' } + + - name: volume + value: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } + + steps: + - name: 'Create Cluster with replica, tablespace' + use: + template: 'templates/create-cluster.yaml' + + - name: 'Create Data' + use: + template: 'templates/psql-data.yaml' + with: + bindings: + - name: target + value: original + - name: job + value: original-data + - name: command + value: | + CREATE SCHEMA IF NOT EXISTS "original"; + CREATE TABLE important (data) AS VALUES ('treasure'); + CREATE TABLE cows (name) TABLESPACE barn AS VALUES ('nellie'); + + - name: 'Create Backup #1' + use: + template: 'templates/create-backup.yaml' + with: + bindings: + - name: annotation + value: one + + - name: 'Clone Cluster #1' + skipDelete: true + use: + template: 'templates/clone-cluster.yaml' + with: + bindings: + - name: name + value: clone-one + + - name: 'Verify Data on Clone #1' + use: + template: 'templates/psql-data.yaml' + with: + bindings: + - name: target + value: clone-one + - name: job + value: clone-one-data + - name: command + value: | + DO $$$$ + DECLARE + restored jsonb; + BEGIN + SELECT jsonb_agg(important) INTO restored FROM important; + ASSERT restored = '[{"data":"treasure"}]', format('got %L', restored); + SELECT jsonb_agg(cows) INTO restored FROM cows; + ASSERT restored = '[{"name":"nellie"}]', format('got %L', restored); + END $$$$; + + - name: 'Delete Cluster #1' + description: > + Delete this clone in the background to free up resources + try: + - delete: + deletionPropagationPolicy: Background + expect: [{ check: { (`true`): true } }] + ref: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + name: clone-one + + - name: 'Update Cluster' + description: > + Update the cluster with parameters that require attention during recovery + use: + template: 'templates/change-parameters.yaml' + with: + bindings: + - name: parameters + value: + max_connections: 1000 + + - name: 'Update Data' + use: + template: 'templates/psql-data.yaml' + with: + bindings: + - name: target + value: original + - name: job + value: original-more-data + - name: command + value: INSERT INTO important (data) VALUES ('water'), ('socks'); + + - name: 'Verify WAL backup' + use: + template: 'templates/verify-backup.yaml' + + - name: 'Create Backup #2' + use: + template: 'templates/create-backup.yaml' + with: + bindings: + - name: annotation + value: two + + - name: 'Clone Cluster #2' + skipDelete: true + use: + template: 'templates/clone-cluster.yaml' + with: + bindings: + - name: name + value: clone-two + + - name: 'Verify Data on Clone #2' + use: + template: 'templates/psql-data.yaml' + with: + bindings: + - name: target + value: clone-two + - name: job + value: clone-two-data + - name: command + value: | + DO $$$$ + DECLARE + restored jsonb; + BEGIN + SELECT jsonb_agg(important) INTO restored FROM important; + ASSERT restored = '[ + {"data":"treasure"}, {"data":"water"}, {"data":"socks"} + ]', format('got %L', restored); + END $$$$; + + - name: 'Delete Cluster #2' + description: > + Delete this clone in the background to free up resources + try: + - delete: + deletionPropagationPolicy: Background + expect: [{ check: { (`true`): true } }] + ref: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + name: clone-two + + - name: 'Lose Data' + description: > + Drop data and ensure that the data is dropped from the replica as well + use: + template: 'templates/lose-data.yaml' + + - name: 'Point-In-Time Restore' + use: + template: 'templates/point-in-time-restore.yaml' + + - name: 'Verify Primary' + description: > + Confirm that data was restored to the point-in-time and the cluster is healthy + use: + template: 'templates/psql-data.yaml' + with: + bindings: + - name: target + value: original + - name: job + value: original-pitr-primary + - name: command + value: | + DO $$$$ + DECLARE + restored jsonb; + BEGIN + SELECT jsonb_agg(important) INTO restored FROM important; + ASSERT restored = '[ + {"data":"treasure"}, {"data":"water"}, {"data":"socks"} + ]', format('got %L', restored); + END $$$$; + + - name: 'Confirm Replica' + description: > + Verify that the data has streamed and is streaming to the replica + use: + template: 'templates/verify-replica.yaml' diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/change-parameters.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/change-parameters.yaml new file mode 100644 index 0000000000..b8650de471 --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/change-parameters.yaml @@ -0,0 +1,91 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: change-parameters +spec: + bindings: + - name: parameters + value: 'The key/value map of Postgres parameters to change' + + try: + - + description: > + Find the primary pod + command: + outputs: + - name: primary + value: (trim_space($stdout)) + entrypoint: kubectl + args: + - get + - pod + - --namespace=${NAMESPACE} + - --output=name + - --selector + - >- + postgres-operator.crunchydata.com/cluster=original, + postgres-operator.crunchydata.com/role=master + + - + description: > + Read the timestamp at which PostgreSQL last started + command: + outputs: + - name: start_before + value: (trim_space($stdout)) + env: + - name: PRIMARY + value: ($primary) + entrypoint: kubectl + args: + - exec + - --namespace=${NAMESPACE} + - ${PRIMARY} + - -- + - psql + - -qAt + - --command + - SELECT pg_postmaster_start_time() + + - + description: > + Update the cluster with parameters that require attention during recovery + patch: + resource: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + metadata: + name: original + spec: + config: + parameters: ($parameters) + + - + description: > + Wait for Postgres to restart, signal the WAL archiver, then wait for WAL to archive + script: + skipCommandOutput: true + timeout: 30s + env: + - name: BEFORE + value: ($start_before) + - name: PRIMARY + value: ($primary) + content: | + while true; do + START=$( + kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ + -- psql -qAt --command 'SELECT pg_postmaster_start_time()' + ) + if [ "${START}" ] && [ "${START}" != "${BEFORE}" ]; then break; else sleep 1; fi + done + echo "${START} != ${BEFORE}" + + # Reset counters in the "pg_stat_archiver" view and signal the archiver. + kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ + -- psql -qb --command "SELECT pg_stat_reset_shared('archiver'), pg_switch_wal()" + + while [ 0 = "$( + kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ + -- psql -qAt --command 'SELECT archived_count FROM pg_stat_archiver' + )" ]; do sleep 1; done diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/clone-cluster.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/clone-cluster.yaml new file mode 100644 index 0000000000..5360ef23fa --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/clone-cluster.yaml @@ -0,0 +1,51 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: clone-cluster +spec: + bindings: + - name: name + value: 'The name of the new PostgresCluster' + + try: + - + description: > + Clone the cluster using a pgBackRest restore + apply: + resource: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + metadata: + name: ($name) + spec: + dataSource: + postgresCluster: + clusterName: original + repoName: repo1 + postgresVersion: ($postgres.version) + instances: + - dataVolumeClaimSpec: ($volume) + tablespaceVolumes: + - { name: barn, dataVolumeClaimSpec: ($volume) } + backups: + pgbackrest: + repos: + - name: repo1 + volume: + volumeClaimSpec: ($volume) + + - + description: > + Wait for the cluster to come online + assert: + resource: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + metadata: + name: ($name) + status: + instances: + - name: '00' + replicas: 1 + readyReplicas: 1 + updatedReplicas: 1 diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/create-backup.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/create-backup.yaml new file mode 100644 index 0000000000..8f71a915f7 --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/create-backup.yaml @@ -0,0 +1,36 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: create-backup +spec: + bindings: + - name: annotation + value: 'The annotation to kick off a backup' + try: + - + description: > + Annotate the cluster to trigger a backup + patch: + resource: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + metadata: + name: original + annotations: + postgres-operator.crunchydata.com/pgbackrest-backup: ($annotation) + + - + description: > + Wait for the backup to complete + assert: + resource: + apiVersion: batch/v1 + kind: Job + metadata: + annotations: + postgres-operator.crunchydata.com/pgbackrest-backup: ($annotation) + labels: + postgres-operator.crunchydata.com/cluster: original + postgres-operator.crunchydata.com/pgbackrest-backup: manual + status: + succeeded: 1 diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/create-cluster.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/create-cluster.yaml new file mode 100644 index 0000000000..295ba9590d --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/create-cluster.yaml @@ -0,0 +1,75 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: create-cluster +spec: + try: + - + description: > + Create a cluster with a single pgBackRest repository + and some parameters that require attention during PostgreSQL recovery + apply: + resource: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + metadata: + name: original + spec: + postgresVersion: ($postgres.version) + config: + parameters: + archive_timeout: 15 + checkpoint_timeout: 30 + max_connections: 200 + instances: + - dataVolumeClaimSpec: ($volume) + tablespaceVolumes: + - { name: barn, dataVolumeClaimSpec: ($volume) } + replicas: 2 + backups: + pgbackrest: + manual: + repoName: repo1 + repos: + - name: repo1 + volume: + volumeClaimSpec: ($volume) + + - + description: > + Wait for the replica backup to complete + assert: + resource: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + metadata: + name: original + status: + pgbackrest: + repos: + - name: repo1 + replicaCreateBackupComplete: true + stanzaCreated: true + + - + description: > + Create a tablespace and grant access to all Postgres users + script: + skipCommandOutput: true + content: | + PRIMARY=$( + kubectl get pod --namespace "${NAMESPACE}" \ + --output name --selector ' + postgres-operator.crunchydata.com/cluster=original, + postgres-operator.crunchydata.com/role=master' + ) + + kubectl exec --stdin --namespace "${NAMESPACE}" "${PRIMARY}" -- psql -q --file=- <<'SQL' + CREATE TABLESPACE barn LOCATION '/tablespaces/barn/data'; + GRANT ALL ON TABLESPACE barn TO public; + SQL + + catch: + - podLogs: + selector: postgres-operator.crunchydata.com/cluster in (original) + tail: 50 diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/lose-data.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/lose-data.yaml new file mode 100644 index 0000000000..39838099ff --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/lose-data.yaml @@ -0,0 +1,123 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: lose-data +spec: + try: + - + description: > + Find the primary pod + command: + outputs: + - name: primary + value: (trim_space($stdout)) + entrypoint: kubectl + args: + - get + - pod + - --namespace=${NAMESPACE} + - --output=name + - --selector + - >- + postgres-operator.crunchydata.com/cluster=original, + postgres-operator.crunchydata.com/role=master + + - + description: > + Store the timestamp reported by PostgreSQL + script: + skipCommandOutput: true + env: + - name: PRIMARY + value: ($primary) + content: | + OBJECTIVE=$( + kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ + -- psql -qAt --command 'SELECT clock_timestamp()' + ) + + # Store the recovery objective for later steps. + kubectl annotate --namespace "${NAMESPACE}" postgrescluster/original \ + "testing/objective=${OBJECTIVE}" + + - + description: > + Drop tables + command: + skipCommandOutput: true + env: + - name: PRIMARY + value: ($primary) + entrypoint: kubectl + args: + - exec + - --namespace=${NAMESPACE} + - ${PRIMARY} + - -- + - psql + - -qb + - original + - --set=ON_ERROR_STOP=1 + - --pset=footer=off + - --command + - DROP TABLE original.important + - --command + - SELECT pg_stat_reset_shared('archiver'), pg_switch_wal() + + - + description: > + Wait for the change to be sent to the WAL archive + script: + skipCommandOutput: true + env: + - name: PRIMARY + value: ($primary) + content: | + while [ 0 = "$( + kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ + -- psql -qAt --command 'SELECT archived_count FROM pg_stat_archiver' + )" ]; do sleep 1; done + + - + description: > + Find the replica pod + command: + outputs: + - name: replica + value: (trim_space($stdout)) + entrypoint: kubectl + args: + - get + - pod + - --namespace=${NAMESPACE} + - --output=name + - --selector + - >- + postgres-operator.crunchydata.com/cluster=original, + postgres-operator.crunchydata.com/role=replica + + - + description: > + Confirm that the replica also has lost the data that was dropped + command: + skipCommandOutput: true + env: + - name: REPLICA + value: ($replica) + entrypoint: kubectl + args: + - exec + - --namespace=${NAMESPACE} + - ${REPLICA} + - -- + - psql + - -qb + - original + - --set=ON_ERROR_STOP=1 + - --command + - | + DO $$$$ + BEGIN + PERFORM * FROM information_schema.tables WHERE table_name = 'important'; + ASSERT NOT FOUND, 'expected no table'; + END $$$$ diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/point-in-time-restore.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/point-in-time-restore.yaml new file mode 100644 index 0000000000..714227ab48 --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/point-in-time-restore.yaml @@ -0,0 +1,70 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: 22-point-in-time-restore +spec: + try: + - + description: > + Read the recovery objective from a prior step + # https://github.com/kyverno/chainsaw/issues/1034 + command: + outputs: + - name: objective + value: ($stdout) + entrypoint: kubectl + args: + - get + - --namespace=${NAMESPACE} + - postgrescluster/original + - --output + - go-template={{ index .metadata.annotations "testing/objective" }} + + - + description: > + Trigger an in-place point-in-time restore (PITR) + patch: + resource: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + metadata: + name: original + annotations: + postgres-operator.crunchydata.com/pgbackrest-restore: one + spec: + backups: + pgbackrest: + restore: + enabled: true + repoName: repo1 + options: + - --type=time + - (join(`"'"`, ['--target=', $objective, ''])) + + - + description: > + Wait for the restore to complete and the cluster to come online + assert: + resource: + apiVersion: postgres-operator.crunchydata.com/v1beta1 + kind: PostgresCluster + metadata: + name: original + status: + instances: + - name: '00' + replicas: 2 + readyReplicas: 2 + updatedReplicas: 2 + pgbackrest: + restore: + id: one + finished: true + + catch: + - + description: > + Read all log lines from the restore job pods + podLogs: + selector: postgres-operator.crunchydata.com/pgbackrest-restore + tail: -1 diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/psql-data.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/psql-data.yaml new file mode 100644 index 0000000000..f779004c22 --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/psql-data.yaml @@ -0,0 +1,75 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: psql-data +spec: + bindings: + - name: target + value: 'The name of the PostgresCluster' + - name: job + value: 'The name of the job' + - name: command + value: 'The command to run on the psql pod' + + try: + # Create bindings derived from the template input. + - command: + entrypoint: 'true' + skipCommandOutput: true + outputs: + - name: secret + value: (join('-', [$target, 'pguser', $target])) + + - + description: > + Run a command through psql to create/verify data + apply: + resource: + apiVersion: batch/v1 + kind: Job + metadata: + name: ($job) + spec: + backoffLimit: 3 + template: + spec: + restartPolicy: Never + containers: + - name: psql + image: ($psql.image) + env: + - ($psql.connect) + - name: PGHOST + valueFrom: { secretKeyRef: { name: ($secret), key: host } } + - name: PGPORT + valueFrom: { secretKeyRef: { name: ($secret), key: port } } + # Always connect to the database using the original dbname, user, password + - name: PGDATABASE + valueFrom: { secretKeyRef: { name: original-pguser-original, key: dbname } } + - name: PGUSER + valueFrom: { secretKeyRef: { name: original-pguser-original, key: user } } + - name: PGPASSWORD + valueFrom: { secretKeyRef: { name: original-pguser-original, key: password } } + command: + - psql + - -qa + - --set=ON_ERROR_STOP=1 + - --command + - ($command) + + - assert: + resource: + apiVersion: batch/v1 + kind: Job + metadata: + name: ($job) + status: + succeeded: 1 + + catch: + - + description: > + Read all log lines from the job pods + podLogs: + selector: (join('', ['batch.kubernetes.io/job-name in (', $job, ')'])) + tail: -1 diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/verify-backup.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/verify-backup.yaml new file mode 100644 index 0000000000..1328f669c5 --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/verify-backup.yaml @@ -0,0 +1,27 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: verify-backup +spec: + try: + - + description: > + Wait for the data to be sent to the WAL archive + script: + skipCommandOutput: true + content: | + PRIMARY=$( + kubectl get pod --namespace "${NAMESPACE}" \ + --output name --selector ' + postgres-operator.crunchydata.com/cluster=original, + postgres-operator.crunchydata.com/role=master' + ) + + kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ + -- psql --command 'SELECT pg_switch_wal()' --pset footer=off + + # A prior step reset the "pg_stat_archiver" counters, so anything more than zero should suffice. + while [ 0 = "$( + kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ + -- psql -qAt --command 'SELECT archived_count FROM pg_stat_archiver' + )" ]; do sleep 1; done diff --git a/testing/chainsaw/e2e/pgbackrest-restore/templates/verify-replica.yaml b/testing/chainsaw/e2e/pgbackrest-restore/templates/verify-replica.yaml new file mode 100644 index 0000000000..496be07b67 --- /dev/null +++ b/testing/chainsaw/e2e/pgbackrest-restore/templates/verify-replica.yaml @@ -0,0 +1,118 @@ +apiVersion: chainsaw.kyverno.io/v1alpha1 +kind: StepTemplate +metadata: + name: 23-verify-replica +spec: + try: + - + description: > + Confirm the replica is also restored and streaming from the primary + apply: + resource: + apiVersion: batch/v1 + kind: Job + metadata: + name: original-pitr-replica + spec: + backoffLimit: 3 + template: + spec: + restartPolicy: Never + containers: + - name: psql + image: ($psql.image) + env: + - ($psql.connect) + - name: PGPORT + valueFrom: { secretKeyRef: { name: original-pguser-original, key: port } } + - name: PGDATABASE + valueFrom: { secretKeyRef: { name: original-pguser-original, key: dbname } } + - name: PGUSER + valueFrom: { secretKeyRef: { name: original-pguser-original, key: user } } + - name: PGPASSWORD + valueFrom: { secretKeyRef: { name: original-pguser-original, key: password } } + + # The user secret does not contain the replica service. + - name: NAMESPACE + valueFrom: { fieldRef: { fieldPath: metadata.namespace } } + - name: PGHOST + value: "original-replicas.$(NAMESPACE).svc" + + # NOTE: the `$$$$` is reduced to `$$` by Kubernetes. + command: + - psql + - -qa + - --set=ON_ERROR_STOP=1 + - --command + - | + DO $$$$ + DECLARE + restored jsonb; + BEGIN + ASSERT pg_is_in_recovery(), 'expected replica'; + + SELECT jsonb_agg(important) INTO restored FROM important; + ASSERT restored = '[ + {"data":"treasure"}, {"data":"water"}, {"data":"socks"} + ]', format('got %L', restored); + END $$$$; + + - assert: + resource: + apiVersion: batch/v1 + kind: Job + metadata: + name: original-pitr-replica + status: + succeeded: 1 + + - + description: > + Find the replica pod + command: + outputs: + - name: replica + value: (trim_space($stdout)) + entrypoint: kubectl + args: + - get + - pod + - --namespace=${NAMESPACE} + - --output=name + - --selector + - >- + postgres-operator.crunchydata.com/cluster=original, + postgres-operator.crunchydata.com/role=replica + + - + description: > + Confirm that the replica is streaming from the primary + command: + skipCommandOutput: true + env: + - name: REPLICA + value: ($replica) + entrypoint: kubectl + args: + - exec + - --namespace=${NAMESPACE} + - ${REPLICA} + - -- + - psql + - -qb + - original + - --set=ON_ERROR_STOP=1 + - --command + - | + DO $$$$ + BEGIN + -- only users with "pg_read_all_stats" role may examine "pg_stat_wal_receiver" + PERFORM * FROM pg_stat_wal_receiver WHERE status = 'streaming'; + ASSERT FOUND, 'expected streaming replication'; + END $$$$ + + catch: + - + podLogs: + selector: batch.kubernetes.io/job-name in (original-pitr-replica) + tail: -1 diff --git a/testing/chainsaw/e2e/values.yaml b/testing/chainsaw/e2e/values.yaml new file mode 100644 index 0000000000..0c8a3ce580 --- /dev/null +++ b/testing/chainsaw/e2e/values.yaml @@ -0,0 +1,5 @@ +versions: + postgres: '17' + +images: + psql: 'registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi9-17.5-2520' diff --git a/testing/kuttl/README.md b/testing/kuttl/README.md index 555ce9a26d..41fbf46e19 100644 --- a/testing/kuttl/README.md +++ b/testing/kuttl/README.md @@ -74,19 +74,10 @@ calling the `make generate-kuttl` target: KUTTL_PG_VERSION=13 KUTTL_POSTGIS_VERSION=3.0 make generate-kuttl ``` -This will loop through the files under the `e2e` and `e2e-other` directories and create matching -files under the `e2e-generated` and `e2e-generated-other` directories that can be checked for +This will loop through the files under testing directories and create matching +files under testing + `generated` directories that can be checked for correctness before running the tests. -Please note, `make check-kuttl` does not run the `e2e-other` tests. To run the `postgis-cluster` -test, you can use: - -``` -kubectl kuttl test testing/kuttl/e2e-generated-other/ --timeout=180 --test postgis-cluster` -``` - -To run the `gssapi` test, please see testing/kuttl/e2e-other/gssapi/README.md. - To prevent errors, we want to set defaults for all the environment variables used in the source YAML files; so if you add a new test with a new variable, please update the Makefile with a reasonable/preferred default. diff --git a/testing/kuttl/e2e-other/autogrow-volume/00-assert.yaml b/testing/kuttl/e2e-other/autogrow-volume/00-assert.yaml deleted file mode 100644 index b4372b75e7..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/00-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Ensure that the default StorageClass supports VolumeExpansion -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - annotations: - storageclass.kubernetes.io/is-default-class: "true" -allowVolumeExpansion: true diff --git a/testing/kuttl/e2e-other/autogrow-volume/01-create.yaml b/testing/kuttl/e2e-other/autogrow-volume/01-create.yaml deleted file mode 100644 index fc947a538f..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/01-create.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/01-create-cluster.yaml -assert: -- files/01-cluster-and-pvc-created.yaml diff --git a/testing/kuttl/e2e-other/autogrow-volume/02-add-data.yaml b/testing/kuttl/e2e-other/autogrow-volume/02-add-data.yaml deleted file mode 100644 index 261c274a51..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/02-add-data.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/02-create-data.yaml -assert: -- files/02-create-data-completed.yaml diff --git a/testing/kuttl/e2e-other/autogrow-volume/03-assert.yaml b/testing/kuttl/e2e-other/autogrow-volume/03-assert.yaml deleted file mode 100644 index ad31b61401..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/03-assert.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -# Check that annotation is set -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: auto-grow-volume - postgres-operator.crunchydata.com/data: postgres - postgres-operator.crunchydata.com/instance-set: instance1 - postgres-operator.crunchydata.com/patroni: auto-grow-volume-ha - annotations: - suggested-pgdata-pvc-size: 1461Mi diff --git a/testing/kuttl/e2e-other/autogrow-volume/04-assert.yaml b/testing/kuttl/e2e-other/autogrow-volume/04-assert.yaml deleted file mode 100644 index d486f9de18..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/04-assert.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# We know that the PVC sizes have changed so now we can check that they have been -# updated to have the expected size ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: auto-grow-volume - postgres-operator.crunchydata.com/instance-set: instance1 -spec: - resources: - requests: - storage: 1461Mi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 2Gi - phase: Bound diff --git a/testing/kuttl/e2e-other/autogrow-volume/05-check-event.yaml b/testing/kuttl/e2e-other/autogrow-volume/05-check-event.yaml deleted file mode 100644 index 475177d242..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/05-check-event.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - # Verify expected event has occurred - - script: | - EVENT=$( - kubectl get events --namespace="${NAMESPACE}" \ - --field-selector reason="VolumeAutoGrow" --output=jsonpath={.items..message} - ) - - if [[ "${EVENT}" != "pgData volume expansion to 1461Mi requested for auto-grow-volume/instance1." ]]; then exit 1; fi diff --git a/testing/kuttl/e2e-other/autogrow-volume/README.md b/testing/kuttl/e2e-other/autogrow-volume/README.md deleted file mode 100644 index 674bc69b40..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/README.md +++ /dev/null @@ -1,9 +0,0 @@ -### AutoGrow Volume - -* 00: Assert the storage class allows volume expansion -* 01: Create and verify PostgresCluster and PVC -* 02: Add data to trigger growth and verify Job completes -* 03: Verify annotation on the instance Pod -* 04: Verify the PVC request has been set and the PVC has grown -* 05: Verify the expansion request Event has been created - Note: This Event should be created between steps 03 and 04 but is checked at the end for timing purposes. diff --git a/testing/kuttl/e2e-other/autogrow-volume/files/01-cluster-and-pvc-created.yaml b/testing/kuttl/e2e-other/autogrow-volume/files/01-cluster-and-pvc-created.yaml deleted file mode 100644 index 17804b8205..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/files/01-cluster-and-pvc-created.yaml +++ /dev/null @@ -1,27 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: auto-grow-volume -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: auto-grow-volume - postgres-operator.crunchydata.com/instance-set: instance1 -spec: - resources: - requests: - storage: 1Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 1Gi - phase: Bound diff --git a/testing/kuttl/e2e-other/autogrow-volume/files/01-create-cluster.yaml b/testing/kuttl/e2e-other/autogrow-volume/files/01-create-cluster.yaml deleted file mode 100644 index 01eaf7a684..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/files/01-create-cluster.yaml +++ /dev/null @@ -1,27 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: auto-grow-volume -spec: - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - name: instance1 - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi - limits: - storage: 2Gi - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi diff --git a/testing/kuttl/e2e-other/autogrow-volume/files/02-create-data-completed.yaml b/testing/kuttl/e2e-other/autogrow-volume/files/02-create-data-completed.yaml deleted file mode 100644 index fdb42e68f5..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/files/02-create-data-completed.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: create-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e-other/autogrow-volume/files/02-create-data.yaml b/testing/kuttl/e2e-other/autogrow-volume/files/02-create-data.yaml deleted file mode 100644 index c42f0dec10..0000000000 --- a/testing/kuttl/e2e-other/autogrow-volume/files/02-create-data.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -# Create some data that should be present after resizing. -apiVersion: batch/v1 -kind: Job -metadata: - name: create-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - name: PGURI - valueFrom: { secretKeyRef: { name: auto-grow-volume-pguser-auto-grow-volume, key: uri } } - - # Do not wait indefinitely, but leave enough time to create the data. - - { name: PGCONNECT_TIMEOUT, value: '60' } - - command: - - psql - - $(PGURI) - - --set=ON_ERROR_STOP=1 - - --command - - | # create schema for user and add enough data to get over 75% usage - CREATE SCHEMA "auto-grow-volume" AUTHORIZATION "auto-grow-volume"; - CREATE TABLE big_table AS SELECT 'data' || s AS mydata FROM generate_series(1,6000000) AS s; diff --git a/testing/kuttl/e2e-other/cluster-migrate/01--non-crunchy-cluster.yaml b/testing/kuttl/e2e-other/cluster-migrate/01--non-crunchy-cluster.yaml deleted file mode 100644 index 1ccceb7098..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/01--non-crunchy-cluster.yaml +++ /dev/null @@ -1,193 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: non-crunchy-cluster - labels: - postgres-operator-test: kuttl - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster -type: Opaque -stringData: - postgres-password: "SR6kNAFXvX" ---- -apiVersion: v1 -kind: Service -metadata: - name: non-crunchy-cluster-hl - labels: - postgres-operator-test: kuttl - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster - app.kubernetes.io/component: primary - service.alpha.kubernetes.io/tolerate-unready-endpoints: "true" -spec: - type: ClusterIP - clusterIP: None - publishNotReadyAddresses: true - ports: - - name: tcp-postgresql - port: 5432 - targetPort: tcp-postgresql - selector: - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster - app.kubernetes.io/component: primary ---- -apiVersion: v1 -kind: Service -metadata: - name: non-crunchy-cluster - labels: - postgres-operator-test: kuttl - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster - app.kubernetes.io/component: primary -spec: - type: ClusterIP - sessionAffinity: None - ports: - - name: tcp-postgresql - port: 5432 - targetPort: tcp-postgresql - nodePort: null - selector: - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster - app.kubernetes.io/component: primary ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: non-crunchy-cluster - labels: - postgres-operator-test: kuttl - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster - app.kubernetes.io/component: primary -spec: - replicas: 1 - serviceName: non-crunchy-cluster-hl - updateStrategy: - rollingUpdate: {} - type: RollingUpdate - selector: - matchLabels: - postgres-operator-test: kuttl - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster - app.kubernetes.io/component: primary - template: - metadata: - name: non-crunchy-cluster - labels: - postgres-operator-test: kuttl - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster - app.kubernetes.io/component: primary - spec: - serviceAccountName: default - affinity: - podAntiAffinity: - preferredDuringSchedulingIgnoredDuringExecution: - - podAffinityTerm: - labelSelector: - matchLabels: - postgres-operator-test: kuttl - app.kubernetes.io/name: postgresql - app.kubernetes.io/instance: non-crunchy-cluster - app.kubernetes.io/component: primary - namespaces: - - "default" - topologyKey: kubernetes.io/hostname - weight: 1 - securityContext: - fsGroup: 1001 - hostNetwork: false - hostIPC: false - containers: - - name: postgresql - image: docker.io/bitnami/postgresql:${KUTTL_BITNAMI_IMAGE_TAG} - imagePullPolicy: "IfNotPresent" - securityContext: - runAsUser: 1001 - env: - - name: BITNAMI_DEBUG - value: "false" - - name: POSTGRESQL_PORT_NUMBER - value: "5432" - - name: POSTGRESQL_VOLUME_DIR - value: "/bitnami/postgresql" - - name: PGDATA - value: "/bitnami/postgresql/data" - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: non-crunchy-cluster - key: postgres-password - - name: POSTGRESQL_ENABLE_LDAP - value: "no" - - name: POSTGRESQL_ENABLE_TLS - value: "no" - - name: POSTGRESQL_LOG_HOSTNAME - value: "false" - - name: POSTGRESQL_LOG_CONNECTIONS - value: "false" - - name: POSTGRESQL_LOG_DISCONNECTIONS - value: "false" - - name: POSTGRESQL_PGAUDIT_LOG_CATALOG - value: "off" - - name: POSTGRESQL_CLIENT_MIN_MESSAGES - value: "error" - - name: POSTGRESQL_SHARED_PRELOAD_LIBRARIES - value: "pgaudit" - ports: - - name: tcp-postgresql - containerPort: 5432 - livenessProbe: - failureThreshold: 6 - initialDelaySeconds: 30 - periodSeconds: 10 - successThreshold: 1 - timeoutSeconds: 5 - exec: - command: - - /bin/sh - - -c - - exec pg_isready -U "postgres" -h localhost -p 5432 - readinessProbe: - failureThreshold: 6 - initialDelaySeconds: 5 - periodSeconds: 10 - successThreshold: 1 - timeoutSeconds: 5 - exec: - command: - - /bin/sh - - -c - - -e - - | - exec pg_isready -U "postgres" -h localhost -p 5432 - [ -f /opt/bitnami/postgresql/tmp/.initialized ] || [ -f /bitnami/postgresql/.initialized ] - resources: - limits: {} - requests: - cpu: 250m - memory: 256Mi - volumeMounts: - - name: dshm - mountPath: /dev/shm - - name: data - mountPath: /bitnami/postgresql - volumes: - - name: dshm - emptyDir: - medium: Memory - volumeClaimTemplates: - - metadata: - name: data - spec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: "1Gi" diff --git a/testing/kuttl/e2e-other/cluster-migrate/01-assert.yaml b/testing/kuttl/e2e-other/cluster-migrate/01-assert.yaml deleted file mode 100644 index c45fe79261..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/01-assert.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: non-crunchy-cluster -status: - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 diff --git a/testing/kuttl/e2e-other/cluster-migrate/02--create-data.yaml b/testing/kuttl/e2e-other/cluster-migrate/02--create-data.yaml deleted file mode 100644 index a9b7ebf152..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/02--create-data.yaml +++ /dev/null @@ -1,30 +0,0 @@ ---- -# Create some data that will be preserved after migration. -apiVersion: batch/v1 -kind: Job -metadata: - name: original-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - { name: PGHOST, value: "non-crunchy-cluster" } - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - { name: PGPASSWORD, valueFrom: { secretKeyRef: { name: non-crunchy-cluster, key: postgres-password } } } - command: - - psql - - --username=postgres - - --dbname=postgres - - --set=ON_ERROR_STOP=1 - - --command - - | - CREATE TABLE IF NOT EXISTS important (data) AS VALUES ('treasure'); diff --git a/testing/kuttl/e2e-other/cluster-migrate/02-assert.yaml b/testing/kuttl/e2e-other/cluster-migrate/02-assert.yaml deleted file mode 100644 index 5115ba97c9..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/02-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: original-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e-other/cluster-migrate/03--alter-pv.yaml b/testing/kuttl/e2e-other/cluster-migrate/03--alter-pv.yaml deleted file mode 100644 index 64fa700297..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/03--alter-pv.yaml +++ /dev/null @@ -1,23 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - set -e - VOLUME_NAME=$( - kubectl get pvc --namespace "${NAMESPACE}" \ - --output=jsonpath={.items..spec.volumeName} - ) - - ORIGINAL_POLICY=$( - kubectl get pv "${VOLUME_NAME}" \ - --output=jsonpath={.spec.persistentVolumeReclaimPolicy} - ) - - kubectl create configmap persistent-volume-reclaim-policy --namespace "${NAMESPACE}" \ - --from-literal=ORIGINAL_POLICY="${ORIGINAL_POLICY}" \ - --from-literal=VOLUME_NAME="${VOLUME_NAME}" - - kubectl patch pv "${VOLUME_NAME}" -p '{"spec":{"persistentVolumeReclaimPolicy":"Retain"}}' - - kubectl label pv "${VOLUME_NAME}" postgres-operator-test=kuttl app.kubernetes.io/name=postgresql app.kubernetes.io/instance=non-crunchy-cluster test-namespace="${NAMESPACE}" diff --git a/testing/kuttl/e2e-other/cluster-migrate/04--delete.yaml b/testing/kuttl/e2e-other/cluster-migrate/04--delete.yaml deleted file mode 100644 index ed38b23d9f..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/04--delete.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -delete: -- apiVersion: apps/v1 - kind: StatefulSet - name: non-crunchy-cluster -- apiVersion: v1 - kind: Service - name: non-crunchy-cluster -- apiVersion: v1 - kind: Service - name: non-crunchy-cluster-hl -- apiVersion: v1 - kind: Secret - name: non-crunchy-cluster diff --git a/testing/kuttl/e2e-other/cluster-migrate/04-errors.yaml b/testing/kuttl/e2e-other/cluster-migrate/04-errors.yaml deleted file mode 100644 index 1767e8040f..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/04-errors.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: non-crunchy-cluster-0 diff --git a/testing/kuttl/e2e-other/cluster-migrate/06-assert.yaml b/testing/kuttl/e2e-other/cluster-migrate/06-assert.yaml deleted file mode 100644 index 1a25966abb..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/06-assert.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: cluster-migrate -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: cluster-migrate - postgres-operator.crunchydata.com/data: postgres - postgres-operator.crunchydata.com/instance-set: instance1 - postgres-operator.crunchydata.com/role: master -status: - phase: Running diff --git a/testing/kuttl/e2e-other/cluster-migrate/07--set-collation.yaml b/testing/kuttl/e2e-other/cluster-migrate/07--set-collation.yaml deleted file mode 100644 index 00eb741f80..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/07--set-collation.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - set -e - if [[ ${KUTTL_PG_VERSION} -ge 15 ]]; then - PRIMARY= - while [[ -z "${PRIMARY}" ]]; do - PRIMARY=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=cluster-migrate, - postgres-operator.crunchydata.com/role=master' - ) - done - - # Ignore warnings about collation changes. This is DANGEROUS on real data! - # Only do this automatic step in test conditions; with real data, this may cause - # more problems as you may need to reindex. - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c database \ - -- psql -qAt --command \ - 'ALTER DATABASE postgres REFRESH COLLATION VERSION; ALTER DATABASE template1 REFRESH COLLATION VERSION;' - fi diff --git a/testing/kuttl/e2e-other/cluster-migrate/08--alter-pv.yaml b/testing/kuttl/e2e-other/cluster-migrate/08--alter-pv.yaml deleted file mode 100644 index c5edfb4c99..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/08--alter-pv.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - set -e - SAVED_DATA=$( - kubectl get configmap persistent-volume-reclaim-policy --namespace "${NAMESPACE}" \ - --output=jsonpath="{.data..['ORIGINAL_POLICY','VOLUME_NAME']}" - ) - - IFS=' ' - read ORIGINAL_POLICY VOLUME_NAME <<< "${SAVED_DATA}" - - kubectl patch pv "${VOLUME_NAME}" -p '{"spec":{"persistentVolumeReclaimPolicy":"'${ORIGINAL_POLICY}'"}}' - diff --git a/testing/kuttl/e2e-other/cluster-migrate/09--check-data.yaml b/testing/kuttl/e2e-other/cluster-migrate/09--check-data.yaml deleted file mode 100644 index 6a46bd8e9a..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/09--check-data.yaml +++ /dev/null @@ -1,23 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - set -e - PRIMARY=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=cluster-migrate, - postgres-operator.crunchydata.com/role=master' - ) - - TREASURE=$( - kubectl exec "${PRIMARY}" --namespace "${NAMESPACE}" \ - --container database \ - -- psql -U postgres -qt -c "select data from important" - ) - - if [[ "${TREASURE}" != " treasure" ]]; then - echo "Migration from 3rd-party PG pod failed, result from query: ${TREASURE}" - exit 1 - fi diff --git a/testing/kuttl/e2e-other/cluster-migrate/README.md b/testing/kuttl/e2e-other/cluster-migrate/README.md deleted file mode 100644 index 09026f9e8b..0000000000 --- a/testing/kuttl/e2e-other/cluster-migrate/README.md +++ /dev/null @@ -1,45 +0,0 @@ -## Cluster Migrate - -This test was developed to check that users could bypass some known problems when -migrating from a non-Crunchy PostgreSQL image to a Crunchy PostgreSQL image: - -1) it changes the ownership of the data directory (which depends on fsGroup -behavior to change group ownership which is not available in all providers); -2) it makes sure a postgresql.conf file is available, as required by Patroni. - -Important note on *environment*: -As noted above, this work relies on fsGroup, so this test will not work in the current -form in all environments. For instance, this creates a PG cluster with fsGroup set, -which will result in an error in OpenShift. - -Important note on *PV permissions*: -This test involves changing permissions on PersistentVolumes, which may not be available -in all environments to all users (since this is a cluster-wide permission). - -Important note on migrating between different builds of *Postgres 15*: -PG 15 introduced new behavior around database collation versions, which result in errors like: - -``` -WARNING: database \"postgres\" has a collation version mismatch -DETAIL: The database was created using collation version 2.31, but the operating system provides version 2.28 -``` - -This error occurred in `reconcilePostgresDatabases` and prevented PGO from finishing the reconcile -loop. For _testing purposes_, this problem is worked around in steps 06 and 07, which wait for -the PG pod to be ready and then send a command to `REFRESH COLLATION VERSION` on the `postgres` -and `template1` databases (which were the only databases where this error was observed during -testing). - -This solution is fine for testing purposes, but is not a solution that should be done in production -as an automatic step. User intervention and supervision is recommended in that case. - -### Steps - -* 01: Create a non-Crunchy PostgreSQL cluster and wait for it to be ready -* 02: Create data on that cluster -* 03: Alter the Reclaim policy of the PV so that it will survive deletion of the cluster -* 04: Delete the original cluster, leaving the PV -* 05: Create a PGO-managed `postgrescluster` with the remaining PV as the datasource -* 06-07: Wait for the PG pod to be ready and alter the collation (PG 15 only, see above) -* 08: Alter the PV to the original Reclaim policy -* 09: Check that the data successfully migrated diff --git a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/10--cluster.yaml b/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/10--cluster.yaml deleted file mode 100644 index a3236da358..0000000000 --- a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/10--cluster.yaml +++ /dev/null @@ -1,29 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: delete-switchover-with-timestamp -spec: - postgresVersion: ${KUTTL_PG_VERSION} - patroni: - switchover: - enabled: true - instances: - - name: instance1 - replicas: 2 - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi diff --git a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/10-assert.yaml b/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/10-assert.yaml deleted file mode 100644 index d77e27e307..0000000000 --- a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/10-assert.yaml +++ /dev/null @@ -1,36 +0,0 @@ ---- -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: delete-switchover-with-timestamp -status: - instances: - - name: instance1 - readyReplicas: 2 - replicas: 2 - updatedReplicas: 2 ---- -# Patroni labels and readiness happen separately. -# The next step expects to find pods by their role label; wait for them here. -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp - postgres-operator.crunchydata.com/role: master ---- -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp - postgres-operator.crunchydata.com/role: replica ---- -apiVersion: batch/v1 -kind: Job -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp - postgres-operator.crunchydata.com/pgbackrest-backup: replica-create -status: - succeeded: 1 diff --git a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/11-annotate.yaml b/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/11-annotate.yaml deleted file mode 100644 index 844d5f1336..0000000000 --- a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/11-annotate.yaml +++ /dev/null @@ -1,19 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - # Label instance pods with their current role. These labels will stick around - # because switchover does not recreate any pods. - - script: | - kubectl label --namespace="${NAMESPACE}" pods \ - --selector='postgres-operator.crunchydata.com/role=master' \ - 'testing/role-before=master' - - script: | - kubectl label --namespace="${NAMESPACE}" pods \ - --selector='postgres-operator.crunchydata.com/role=replica' \ - 'testing/role-before=replica' - - # Annotate the cluster to trigger a switchover. - - script: | - kubectl annotate --namespace="${NAMESPACE}" postgrescluster/delete-switchover-with-timestamp \ - "postgres-operator.crunchydata.com/trigger-switchover=$(date)" diff --git a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/12-assert.yaml b/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/12-assert.yaml deleted file mode 100644 index 76f0f8dff6..0000000000 --- a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/12-assert.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -# Wait for switchover to finish. A former replica should now be the primary. -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp - postgres-operator.crunchydata.com/data: postgres - postgres-operator.crunchydata.com/role: master - testing/role-before: replica ---- -# The former primary should now be a replica. -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp - postgres-operator.crunchydata.com/data: postgres - postgres-operator.crunchydata.com/role: replica - testing/role-before: master ---- -# All instances should be healthy. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: delete-switchover-with-timestamp -status: - instances: - - name: instance1 - replicas: 2 - readyReplicas: 2 - updatedReplicas: 2 diff --git a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/13-delete-cluster-and-check.yaml b/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/13-delete-cluster-and-check.yaml deleted file mode 100644 index 45352cca2e..0000000000 --- a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/13-delete-cluster-and-check.yaml +++ /dev/null @@ -1,47 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - # Get the names of the current primary and replica -- error if either is blank - # Delete the cluster - # Get the delete event for the pods - # Verify that the replica delete event is greater than the primary delete event - - script: | - PRIMARY=$( - kubectl get pods --namespace="${NAMESPACE}" \ - --selector='postgres-operator.crunchydata.com/role=master' \ - --output=jsonpath={.items..metadata.name} - ) - - REPLICA=$( - kubectl get pods --namespace="${NAMESPACE}" \ - --selector='postgres-operator.crunchydata.com/role=replica' \ - --output=jsonpath={.items..metadata.name} - ) - - echo "DELETE: Found primary ${PRIMARY} and replica ${REPLICA} pods" - - if [ -z "$PRIMARY" ]; then exit 1; fi - if [ -z "$REPLICA" ]; then exit 1; fi - - kubectl delete postgrescluster -n "${NAMESPACE}" delete-switchover-with-timestamp - - kubectl wait "pod/${REPLICA}" --namespace "${NAMESPACE}" --for=delete --timeout=180s - - KILLING_REPLICA_TIMESTAMP=$( - kubectl get events --namespace="${NAMESPACE}" \ - --field-selector reason="Killing",involvedObject.fieldPath="spec.containers{database}",involvedObject.name="${REPLICA}" \ - --output=jsonpath={.items..firstTimestamp} - ) - - kubectl wait "pod/${PRIMARY}" --namespace "${NAMESPACE}" --for=delete --timeout=180s - - KILLING_PRIMARY_TIMESTAMP=$( - kubectl get events --namespace="${NAMESPACE}" \ - --field-selector reason="Killing",involvedObject.fieldPath="spec.containers{database}",involvedObject.name="${PRIMARY}" \ - --output=jsonpath={.items..firstTimestamp} - ) - - echo "DELETE: Found primary ${KILLING_PRIMARY_TIMESTAMP} and replica ${KILLING_REPLICA_TIMESTAMP} timestamps" - - if [[ "${KILLING_PRIMARY_TIMESTAMP}" < "${KILLING_REPLICA_TIMESTAMP}" ]]; then exit 1; fi diff --git a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/14-errors.yaml b/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/14-errors.yaml deleted file mode 100644 index 2a1015824b..0000000000 --- a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/14-errors.yaml +++ /dev/null @@ -1,42 +0,0 @@ ---- -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: delete-switchover-with-timestamp ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp ---- -# Patroni DCS objects are not owned by the PostgresCluster. -apiVersion: v1 -kind: Endpoints -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp ---- -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp ---- -apiVersion: v1 -kind: Service -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp ---- -apiVersion: v1 -kind: Secret -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp ---- -apiVersion: v1 -kind: ConfigMap -metadata: - labels: - postgres-operator.crunchydata.com/cluster: delete-switchover-with-timestamp diff --git a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/README.md b/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/README.md deleted file mode 100644 index bf914aa6cf..0000000000 --- a/testing/kuttl/e2e-other/delete-with-replica-and-check-timestamps/README.md +++ /dev/null @@ -1,7 +0,0 @@ -This test originally existed as the second test-case in the `delete` KUTTL test. -The test as written was prone to occasional flakes, sometimes due to missing events -(which were being used to check the timestamp of the container delete event). - -After discussion, we decided that this behavior (replica deleting before the primary) -was no longer required in v5, and the decision was made to sequester this test-case for -further testing and refinement. \ No newline at end of file diff --git a/testing/kuttl/e2e-other/exporter-append-custom-queries/00--create-cluster.yaml b/testing/kuttl/e2e-other/exporter-append-custom-queries/00--create-cluster.yaml deleted file mode 100644 index bc515e3534..0000000000 --- a/testing/kuttl/e2e-other/exporter-append-custom-queries/00--create-cluster.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/exporter-append-queries-configmap.yaml -- files/exporter-append-queries-cluster.yaml -assert: -- files/exporter-append-queries-cluster-checks.yaml diff --git a/testing/kuttl/e2e-other/exporter-append-custom-queries/00-assert.yaml b/testing/kuttl/e2e-other/exporter-append-custom-queries/00-assert.yaml deleted file mode 100644 index 2655841597..0000000000 --- a/testing/kuttl/e2e-other/exporter-append-custom-queries/00-assert.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -commands: -# First, check that all containers in the instance pod are ready -# Then, list the query files mounted to the exporter and check for expected files -# Finally, check the contents of the queries to ensure queries.yml was generated correctly -- script: | - retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } - check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - - pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ - -l postgres-operator.crunchydata.com/cluster=exporter-append-queries \ - -l postgres-operator.crunchydata.com/crunchy-postgres-exporter=true) - [ "$pod" = "" ] && retry "Pod not found" && exit 1 - - condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") - [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 - { check_containers_ready "$condition_json"; } || { - retry "containers not ready" - exit 1 - } - - queries_files=$( - kubectl exec --namespace "${NAMESPACE}" "${pod}" -c exporter \ - -- ls /conf - ) - - { - contains "${queries_files}" "queries.yml" && - contains "${queries_files}" "defaultQueries.yml" - } || { - echo >&2 'The /conf directory should contain queries.yml and defaultQueries.yml. Instead it has:' - echo "${queries_files}" - exit 1 - } - - master_queries_contents=$( - kubectl exec --namespace "${NAMESPACE}" "${pod}" -c exporter \ - -- cat /tmp/queries.yml - ) - - { - contains "${master_queries_contents}" "# This is a test." && - contains "${master_queries_contents}" "ccp_postgresql_version" - } || { - echo >&2 'The master queries.yml file should contain the contents of both defaultQueries.yml and the custom queries.yml file. Instead it contains:' - echo "${master_queries_contents}" - exit 1 - } diff --git a/testing/kuttl/e2e-other/exporter-append-custom-queries/README.md b/testing/kuttl/e2e-other/exporter-append-custom-queries/README.md deleted file mode 100644 index a24aa444c7..0000000000 --- a/testing/kuttl/e2e-other/exporter-append-custom-queries/README.md +++ /dev/null @@ -1,5 +0,0 @@ -Exporter - AppendCustomQueries Enabled - -Note: This series of tests depends on PGO being deployed with the AppendCustomQueries feature gate ON. There is a separate set of tests in e2e that tests exporter functionality without the AppendCustomQueries feature. - -When running this test, make sure that the PGO_FEATURE_GATES environment variable is set to "AppendCustomQueries=true" on the PGO Deployment. diff --git a/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-cluster-checks.yaml b/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-cluster-checks.yaml deleted file mode 100644 index 459356ddfc..0000000000 --- a/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-cluster-checks.yaml +++ /dev/null @@ -1,29 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: exporter-append-queries -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: exporter-append-queries - postgres-operator.crunchydata.com/crunchy-postgres-exporter: "true" -status: - phase: Running ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: exporter-append-queries-exporter-queries-config ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: custom-queries-test diff --git a/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-cluster.yaml b/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-cluster.yaml deleted file mode 100644 index c4f75771aa..0000000000 --- a/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-cluster.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: exporter-append-queries -spec: - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - name: instance1 - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - monitoring: - pgmonitor: - exporter: - configuration: - - configMap: - name: custom-queries-test diff --git a/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-configmap.yaml b/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-configmap.yaml deleted file mode 100644 index 9964d6bc1e..0000000000 --- a/testing/kuttl/e2e-other/exporter-append-custom-queries/files/exporter-append-queries-configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: custom-queries-test -data: - queries.yml: "# This is a test." diff --git a/testing/kuttl/e2e-other/exporter-replica/00--create-cluster.yaml b/testing/kuttl/e2e-other/exporter-replica/00--create-cluster.yaml deleted file mode 100644 index 2abec0814e..0000000000 --- a/testing/kuttl/e2e-other/exporter-replica/00--create-cluster.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/exporter-replica-cluster.yaml -assert: -- files/exporter-replica-cluster-checks.yaml diff --git a/testing/kuttl/e2e-other/exporter-replica/00-assert.yaml b/testing/kuttl/e2e-other/exporter-replica/00-assert.yaml deleted file mode 100644 index 280be2d395..0000000000 --- a/testing/kuttl/e2e-other/exporter-replica/00-assert.yaml +++ /dev/null @@ -1,45 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -commands: -# First, check that all containers in the instance(s) pod are ready -# Then, grab the exporter metrics output and check that there were no scrape errors -# Finally, ensure the monitoring user exists and is configured -- script: | - retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } - check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - - replica=$(kubectl get pods -o name -n "${NAMESPACE}" \ - -l postgres-operator.crunchydata.com/cluster=exporter-replica \ - -l postgres-operator.crunchydata.com/crunchy-postgres-exporter=true \ - -l postgres-operator.crunchydata.com/role=replica) - [ "$replica" = "" ] && retry "Replica Pod not found" && exit 1 - - replica_condition_json=$(kubectl get "${replica}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") - [ "$replica_condition_json" = "" ] && retry "Replica conditions not found" && exit 1 - { - check_containers_ready "$replica_condition_json" - } || { - retry "containers not ready" - exit 1 - } - - scrape_metrics=$(kubectl exec ${replica} -c exporter -n ${NAMESPACE} -- \ - curl --silent http://localhost:9187/metrics | grep "pg_exporter_last_scrape_error") - { - contains "${scrape_metrics}" 'pg_exporter_last_scrape_error 0'; - } || { - retry "${scrape_metrics}" - exit 1 - } - - kubectl exec --stdin "${replica}" --namespace "${NAMESPACE}" -c database \ - -- psql -qb --set ON_ERROR_STOP=1 --file=- <<'SQL' - DO $$ - DECLARE - result record; - BEGIN - SELECT * INTO result FROM pg_catalog.pg_roles WHERE rolname = 'ccp_monitoring'; - ASSERT FOUND, 'user not found'; - END $$ - SQL diff --git a/testing/kuttl/e2e-other/exporter-replica/files/exporter-replica-cluster-checks.yaml b/testing/kuttl/e2e-other/exporter-replica/files/exporter-replica-cluster-checks.yaml deleted file mode 100644 index 7c775b47b1..0000000000 --- a/testing/kuttl/e2e-other/exporter-replica/files/exporter-replica-cluster-checks.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: exporter-replica -status: - instances: - - name: instance1 - readyReplicas: 2 - replicas: 2 - updatedReplicas: 2 ---- -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: exporter-replica - postgres-operator.crunchydata.com/crunchy-postgres-exporter: "true" -status: - phase: Running ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: exporter-replica-exporter-queries-config diff --git a/testing/kuttl/e2e-other/exporter-replica/files/exporter-replica-cluster.yaml b/testing/kuttl/e2e-other/exporter-replica/files/exporter-replica-cluster.yaml deleted file mode 100644 index 504d33bc3a..0000000000 --- a/testing/kuttl/e2e-other/exporter-replica/files/exporter-replica-cluster.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: exporter-replica -spec: - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - name: instance1 - replicas: 2 - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - monitoring: - pgmonitor: - exporter: {} diff --git a/testing/kuttl/e2e-other/exporter-standby/01--create-primary.yaml b/testing/kuttl/e2e-other/exporter-standby/01--create-primary.yaml deleted file mode 100644 index 6b5b721d4e..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/01--create-primary.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/primary-cluster.yaml -assert: -- files/primary-cluster-checks.yaml diff --git a/testing/kuttl/e2e-other/exporter-standby/01-assert.yaml b/testing/kuttl/e2e-other/exporter-standby/01-assert.yaml deleted file mode 100644 index cd2d16c783..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/01-assert.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -commands: -# Store the exporter pid as an annotation on the pod -- script: | - retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } - check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } - - pod=$(kubectl get pods -o name -n $NAMESPACE \ - -l postgres-operator.crunchydata.com/cluster=primary-cluster \ - -l postgres-operator.crunchydata.com/crunchy-postgres-exporter=true) - [ "$pod" = "" ] && retry "Pod not found" && exit 1 - - condition_json=$(kubectl get ${pod} -n ${NAMESPACE} -o jsonpath="{.status.conditions}") - [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 - { check_containers_ready "$condition_json"; } || { - retry "containers not ready" - exit 1 - } - - pid=$(kubectl exec ${pod} -n ${NAMESPACE} -c exporter -- cat /tmp/postgres_exporter.pid) - kubectl annotate --overwrite -n ${NAMESPACE} ${pod} oldpid=${pid} diff --git a/testing/kuttl/e2e-other/exporter-standby/02--set-primary-password.yaml b/testing/kuttl/e2e-other/exporter-standby/02--set-primary-password.yaml deleted file mode 100644 index 4e613a277f..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/02--set-primary-password.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/update-primary-password.yaml -assert: -- files/update-primary-password-checks.yaml diff --git a/testing/kuttl/e2e-other/exporter-standby/03--create-standby.yaml b/testing/kuttl/e2e-other/exporter-standby/03--create-standby.yaml deleted file mode 100644 index fa2e653353..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/03--create-standby.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/standby-cluster.yaml -assert: -- files/standby-cluster-checks.yaml diff --git a/testing/kuttl/e2e-other/exporter-standby/03-assert.yaml b/testing/kuttl/e2e-other/exporter-standby/03-assert.yaml deleted file mode 100644 index 327e5562fa..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/03-assert.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -commands: -# Grab the exporter pod -# Check that the postgres_exporter pid is running -# Store the exporter pid as an annotation on the pod -- script: | - retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } - check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } - - pod=$(kubectl get pods -o name -n $NAMESPACE \ - -l postgres-operator.crunchydata.com/cluster=standby-cluster,postgres-operator.crunchydata.com/crunchy-postgres-exporter=true) - [ "$pod" = "" ] && retry "Pod not found" && exit 1 - - pid=$(kubectl exec ${pod} -n ${NAMESPACE} -c exporter -- cat /tmp/postgres_exporter.pid) - kubectl annotate --overwrite -n ${NAMESPACE} ${pod} oldpid=${pid} diff --git a/testing/kuttl/e2e-other/exporter-standby/04--set-standby-password.yaml b/testing/kuttl/e2e-other/exporter-standby/04--set-standby-password.yaml deleted file mode 100644 index 18c98e423e..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/04--set-standby-password.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/update-standby-password.yaml -assert: -- files/update-standby-password-checks.yaml diff --git a/testing/kuttl/e2e-other/exporter-standby/04-assert.yaml b/testing/kuttl/e2e-other/exporter-standby/04-assert.yaml deleted file mode 100644 index 7e77784a65..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/04-assert.yaml +++ /dev/null @@ -1,38 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -commands: -# Grab the exporter pod -# Check that the postgres_exporter pid is running -# Store the exporter pid as an annotation on the pod -- script: | - retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - check_containers_ready() { bash -ceu ' echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@";} - - pod=$(kubectl get pods -o name -n $NAMESPACE \ - -l postgres-operator.crunchydata.com/cluster=standby-cluster,postgres-operator.crunchydata.com/crunchy-postgres-exporter=true) - [ "$pod" = "" ] && retry "Pod not found" && exit 1 - - oldPid=$(kubectl get ${pod} -n ${NAMESPACE} -o jsonpath="{.metadata.annotations.oldpid}") - newPid=$(kubectl exec ${pod} -n ${NAMESPACE} -c exporter -- cat /tmp/postgres_exporter.pid) - [ "${oldPid}" -eq "${newPid}" ] && retry "pid should have changed" && exit 1 - - password=$(kubectl exec -n ${NAMESPACE} ${pod} -c exporter -- bash -c 'cat /opt/crunchy/password') - { contains "${password}" "password"; } || { - retry "unexpected password: ${password}" - exit 1 - } - - condition_json=$(kubectl get ${pod} -n ${NAMESPACE} -o jsonpath="{.status.conditions}") - [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 - { check_containers_ready "$condition_json"; } || { - retry "containers not ready" - exit 1 - } - - scrape_metrics=$(kubectl exec ${pod} -c exporter -n ${NAMESPACE} -- \ - curl --silent http://localhost:9187/metrics | grep "pg_exporter_last_scrape_error") - { contains "${scrape_metrics}" 'pg_exporter_last_scrape_error 0'; } || { - retry "${scrape_metrics}" - exit 1 - } diff --git a/testing/kuttl/e2e-other/exporter-standby/README.md b/testing/kuttl/e2e-other/exporter-standby/README.md deleted file mode 100644 index 34df4e5b7a..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Exporter connection on standby cluster - -The exporter standby test will deploy two clusters, one primary and one standby. -Both clusters have monitoring enabled and are created in the same namespace to -allow for easy connections over the network. - -The `ccp_monitoring` password for both clusters are updated to match allowing -the exporter on the standby cluster to query postgres using the proper `ccp_monitoring` -password. diff --git a/testing/kuttl/e2e-other/exporter-standby/files/cluster-certs.yaml b/testing/kuttl/e2e-other/exporter-standby/files/cluster-certs.yaml deleted file mode 100644 index 1f8dd06ccf..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/cluster-certs.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v1 -data: - ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJnakNDQVNlZ0F3SUJBZ0lRZUpacWMxMmR3TDh6cDNRVjZVMzg0ekFLQmdncWhrak9QUVFEQXpBZk1SMHcKR3dZRFZRUURFeFJ3YjNOMFozSmxjeTF2Y0dWeVlYUnZjaTFqWVRBZUZ3MHlNekEwTVRFeE56UTFNemhhRncwegpNekEwTURneE9EUTFNemhhTUI4eEhUQWJCZ05WQkFNVEZIQnZjM1JuY21WekxXOXdaWEpoZEc5eUxXTmhNRmt3CkV3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFWEZwMU1nOFQ0aWxFRFlleVh4Nm5hRU0weEtNUStNZU0KWnM3dUtockdmTnY1cVd3N0puNzJEMEZNWE9raVNTN1BsZUhtN1lwYk1lelZ4UytjLzV6a2NLTkZNRU13RGdZRApWUjBQQVFIL0JBUURBZ0VHTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0hRWURWUjBPQkJZRUZGU2JSZzdXCnpIZFdIODN2aEtTcld3dGV4K2FtTUFvR0NDcUdTTTQ5QkFNREEwa0FNRVlDSVFDK3pXTHh4bmpna1ZYYzBFOVAKbWlmZm9jeTIrM3AxREZMUkJRcHlZNFE0RVFJaEFPSDhQVEtvWnRZUWlobVlqTkd3Q1J3aTgvVFRaYWIxSnVIMAo2YnpodHZobgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== - tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNQakNDQWVXZ0F3SUJBZ0lSQU93NURHaGVVZnVNY25KYVdKNkllall3Q2dZSUtvWkl6ajBFQXdNd0h6RWQKTUJzR0ExVUVBeE1VY0c5emRHZHlaWE10YjNCbGNtRjBiM0l0WTJFd0hoY05Nak13TkRFeE1UYzBOVE01V2hjTgpNek13TkRBNE1UZzBOVE01V2pBOU1Uc3dPUVlEVlFRREV6SndjbWx0WVhKNUxXTnNkWE4wWlhJdGNISnBiV0Z5CmVTNWtaV1poZFd4MExuTjJZeTVqYkhWemRHVnlMbXh2WTJGc0xqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDkKQXdFSEEwSUFCT3RlNytQWFlDci9RQVJkcHlwYTFHcEpkbW5wOFN3ZG9FOTIzUXoraWt4UllTalgwUHBXcytqUQpVNXlKZ0NDdGxyZmxFZVZ4S2YzaVpiVHdadFlIaHVxamdlTXdnZUF3RGdZRFZSMFBBUUgvQkFRREFnV2dNQXdHCkExVWRFd0VCL3dRQ01BQXdId1lEVlIwakJCZ3dGb0FVVkp0R0R0Yk1kMVlmemUrRXBLdGJDMTdINXFZd2daNEcKQTFVZEVRU0JsakNCazRJeWNISnBiV0Z5ZVMxamJIVnpkR1Z5TFhCeWFXMWhjbmt1WkdWbVlYVnNkQzV6ZG1NdQpZMngxYzNSbGNpNXNiMk5oYkM2Q0kzQnlhVzFoY25rdFkyeDFjM1JsY2kxd2NtbHRZWEo1TG1SbFptRjFiSFF1CmMzWmpnaDl3Y21sdFlYSjVMV05zZFhOMFpYSXRjSEpwYldGeWVTNWtaV1poZFd4MGdoZHdjbWx0WVhKNUxXTnMKZFhOMFpYSXRjSEpwYldGeWVUQUtCZ2dxaGtqT1BRUURBd05IQURCRUFpQjA3Q3YzRHJTNXUxRFdaek1MQjdvbAppcjFFWEpQTnFaOXZWQUF5ZTdDMGJRSWdWQVlDM2F0ekl4a0syNHlQUU1TSjU1OGFaN3JEdkZGZXdOaVpmdSt0CjdETT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= - tls.key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUoxYkNXMTByR3o2VWQ1K2R3WmZWcGNUNFlqck9XVG1iVW9XNXRxYTA2b1ZvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFNjE3djQ5ZGdLdjlBQkYybktsclVha2wyYWVueExCMmdUM2JkRFA2S1RGRmhLTmZRK2xhego2TkJUbkltQUlLMld0K1VSNVhFcC9lSmx0UEJtMWdlRzZnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= -kind: Secret -metadata: - name: cluster-cert -type: Opaque ---- -apiVersion: v1 -data: - ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJnakNDQVNlZ0F3SUJBZ0lRZUpacWMxMmR3TDh6cDNRVjZVMzg0ekFLQmdncWhrak9QUVFEQXpBZk1SMHcKR3dZRFZRUURFeFJ3YjNOMFozSmxjeTF2Y0dWeVlYUnZjaTFqWVRBZUZ3MHlNekEwTVRFeE56UTFNemhhRncwegpNekEwTURneE9EUTFNemhhTUI4eEhUQWJCZ05WQkFNVEZIQnZjM1JuY21WekxXOXdaWEpoZEc5eUxXTmhNRmt3CkV3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFWEZwMU1nOFQ0aWxFRFlleVh4Nm5hRU0weEtNUStNZU0KWnM3dUtockdmTnY1cVd3N0puNzJEMEZNWE9raVNTN1BsZUhtN1lwYk1lelZ4UytjLzV6a2NLTkZNRU13RGdZRApWUjBQQVFIL0JBUURBZ0VHTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0hRWURWUjBPQkJZRUZGU2JSZzdXCnpIZFdIODN2aEtTcld3dGV4K2FtTUFvR0NDcUdTTTQ5QkFNREEwa0FNRVlDSVFDK3pXTHh4bmpna1ZYYzBFOVAKbWlmZm9jeTIrM3AxREZMUkJRcHlZNFE0RVFJaEFPSDhQVEtvWnRZUWlobVlqTkd3Q1J3aTgvVFRaYWIxSnVIMAo2YnpodHZobgotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== - tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJqekNDQVRTZ0F3SUJBZ0lRRzA0MEprWjYwZkZtanpaVG1SekhyakFLQmdncWhrak9QUVFEQXpBZk1SMHcKR3dZRFZRUURFeFJ3YjNOMFozSmxjeTF2Y0dWeVlYUnZjaTFqWVRBZUZ3MHlNekEwTVRFeE56UTFNemhhRncwegpNekEwTURneE9EUTFNemhhTUJjeEZUQVRCZ05WQkFNTURGOWpjblZ1WTJoNWNtVndiREJaTUJNR0J5cUdTTTQ5CkFnRUdDQ3FHU000OUF3RUhBMElBQk5HVHcvSmVtaGxGK28xUlRBb0VXSndzdjJ6WjIyc1p4N2NjT2VmL1NXdjYKeXphYkpaUmkvREFyK0kwUHNyTlhmand3a0xMa3hERGZsTklvcFZMNVYwT2pXakJZTUE0R0ExVWREd0VCL3dRRQpBd0lGb0RBTUJnTlZIUk1CQWY4RUFqQUFNQjhHQTFVZEl3UVlNQmFBRkZTYlJnN1d6SGRXSDgzdmhLU3JXd3RlCngrYW1NQmNHQTFVZEVRUVFNQTZDREY5amNuVnVZMmg1Y21Wd2JEQUtCZ2dxaGtqT1BRUURBd05KQURCR0FpRUEKcWVsYmUvdTQzRFRPWFdlell1b3Nva0dUbHg1U2ljUFRkNk05Q3pwU2VoWUNJUUNOOS91Znc0SUZzdDZOM1RtYQo4MmZpSElKSUpQY0RjM2ZKUnFna01RQmF0QT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K - tls.key: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSVBxeTVzNVJxWThKUmdycjJreE9zaG9hc25yTWhUUkJPYjZ0alI3T2ZqTFlvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFMFpQRDhsNmFHVVg2alZGTUNnUlluQ3kvYk5uYmF4bkh0eHc1NS85SmEvckxOcHNsbEdMOApNQ3Y0alEreXMxZCtQRENRc3VURU1OK1UwaWlsVXZsWFF3PT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= -kind: Secret -metadata: - name: replication-cert -type: Opaque diff --git a/testing/kuttl/e2e-other/exporter-standby/files/primary-cluster-checks.yaml b/testing/kuttl/e2e-other/exporter-standby/files/primary-cluster-checks.yaml deleted file mode 100644 index c2a59244a5..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/primary-cluster-checks.yaml +++ /dev/null @@ -1,20 +0,0 @@ ---- -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: primary-cluster -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: primary-cluster - postgres-operator.crunchydata.com/crunchy-postgres-exporter: "true" -status: - phase: Running diff --git a/testing/kuttl/e2e-other/exporter-standby/files/primary-cluster.yaml b/testing/kuttl/e2e-other/exporter-standby/files/primary-cluster.yaml deleted file mode 100644 index 8f51632f5b..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/primary-cluster.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: primary-cluster -spec: - postgresVersion: ${KUTTL_PG_VERSION} - customTLSSecret: - name: cluster-cert - customReplicationTLSSecret: - name: replication-cert - instances: - - name: instance1 - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - monitoring: - pgmonitor: - exporter: {} diff --git a/testing/kuttl/e2e-other/exporter-standby/files/standby-cluster-checks.yaml b/testing/kuttl/e2e-other/exporter-standby/files/standby-cluster-checks.yaml deleted file mode 100644 index 237dec721e..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/standby-cluster-checks.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: standby-cluster -status: - instances: - - name: instance1 - replicas: 1 - updatedReplicas: 1 - # The cluster should not become fully ready in this step, the ccp_monitoring password - # on the standby does not match the primary ---- -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: standby-cluster - postgres-operator.crunchydata.com/crunchy-postgres-exporter: "true" -status: - phase: Running diff --git a/testing/kuttl/e2e-other/exporter-standby/files/standby-cluster.yaml b/testing/kuttl/e2e-other/exporter-standby/files/standby-cluster.yaml deleted file mode 100644 index 33e9ec2c2c..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/standby-cluster.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: standby-cluster -spec: - postgresVersion: ${KUTTL_PG_VERSION} - standby: - enabled: true - host: primary-cluster-primary - customTLSSecret: - name: cluster-cert - customReplicationTLSSecret: - name: replication-cert - instances: - - name: instance1 - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - monitoring: - pgmonitor: - exporter: {} diff --git a/testing/kuttl/e2e-other/exporter-standby/files/update-primary-password-checks.yaml b/testing/kuttl/e2e-other/exporter-standby/files/update-primary-password-checks.yaml deleted file mode 100644 index 1ef72b49c9..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/update-primary-password-checks.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: primary-cluster-monitoring - labels: - postgres-operator.crunchydata.com/cluster: primary-cluster - postgres-operator.crunchydata.com/role: monitoring - ownerReferences: - - apiVersion: postgres-operator.crunchydata.com/v1beta1 - blockOwnerDeletion: true - controller: true - kind: PostgresCluster - name: primary-cluster -data: - # ensure the password is encoded to 'password' - password: cGFzc3dvcmQ= ---- -# TODO: Check that password is set as a file diff --git a/testing/kuttl/e2e-other/exporter-standby/files/update-primary-password.yaml b/testing/kuttl/e2e-other/exporter-standby/files/update-primary-password.yaml deleted file mode 100644 index a66450b103..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/update-primary-password.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: primary-cluster-monitoring - labels: - postgres-operator.crunchydata.com/cluster: primary-cluster - postgres-operator.crunchydata.com/role: monitoring -stringData: - password: password -data: -# Ensure data field is deleted so that password/verifier will be regenerated diff --git a/testing/kuttl/e2e-other/exporter-standby/files/update-standby-password-checks.yaml b/testing/kuttl/e2e-other/exporter-standby/files/update-standby-password-checks.yaml deleted file mode 100644 index 34d5357318..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/update-standby-password-checks.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: standby-cluster-monitoring - labels: - postgres-operator.crunchydata.com/cluster: standby-cluster - postgres-operator.crunchydata.com/role: monitoring - ownerReferences: - - apiVersion: postgres-operator.crunchydata.com/v1beta1 - blockOwnerDeletion: true - controller: true - kind: PostgresCluster - name: standby-cluster -data: - # ensure the password is encoded to 'password' - password: cGFzc3dvcmQ= ---- -# TODO: Check that password is set as a file diff --git a/testing/kuttl/e2e-other/exporter-standby/files/update-standby-password.yaml b/testing/kuttl/e2e-other/exporter-standby/files/update-standby-password.yaml deleted file mode 100644 index 57371fce93..0000000000 --- a/testing/kuttl/e2e-other/exporter-standby/files/update-standby-password.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: standby-cluster-monitoring - labels: - postgres-operator.crunchydata.com/cluster: standby-cluster - postgres-operator.crunchydata.com/role: monitoring -stringData: - password: password -data: -# Ensure data field is deleted so that password/verifier will be regenerated diff --git a/testing/kuttl/e2e-other/exporter-upgrade/00--cluster.yaml b/testing/kuttl/e2e-other/exporter-upgrade/00--cluster.yaml deleted file mode 100644 index 0e53eab2de..0000000000 --- a/testing/kuttl/e2e-other/exporter-upgrade/00--cluster.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: exporter -spec: - postgresVersion: 14 - image: us.gcr.io/container-suite/crunchy-postgres:ubi8-14.0-5.0.3-0 - instances: - - name: instance1 - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi - monitoring: - pgmonitor: - exporter: - image: registry.developers.crunchydata.com/crunchydata/crunchy-postgres-exporter:ubi8-5.3.1-0 diff --git a/testing/kuttl/e2e-other/exporter-upgrade/00-assert.yaml b/testing/kuttl/e2e-other/exporter-upgrade/00-assert.yaml deleted file mode 100644 index c569c97454..0000000000 --- a/testing/kuttl/e2e-other/exporter-upgrade/00-assert.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: exporter -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 diff --git a/testing/kuttl/e2e-other/exporter-upgrade/01--check-exporter.yaml b/testing/kuttl/e2e-other/exporter-upgrade/01--check-exporter.yaml deleted file mode 100644 index 0e72f2a0bf..0000000000 --- a/testing/kuttl/e2e-other/exporter-upgrade/01--check-exporter.yaml +++ /dev/null @@ -1,31 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - set -e - PRIMARY=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=exporter, - postgres-operator.crunchydata.com/role=master' - ) - - # Ensure that the metrics endpoint is available from inside the exporter container - for i in {1..5}; do - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -c exporter -- curl http://localhost:9187/metrics - sleep 2 - done - - # Ensure that the monitoring user exists and is configured. - kubectl exec --stdin --namespace "${NAMESPACE}" "${PRIMARY}" \ - -- psql -qb --set ON_ERROR_STOP=1 --file=- <<'SQL' - DO $$ - DECLARE - result record; - BEGIN - SELECT * INTO result FROM pg_catalog.pg_roles WHERE rolname = 'ccp_monitoring'; - ASSERT FOUND, 'user not found'; - ASSERT result.rolconfig @> '{jit=off}', format('got config: %L', result.rolconfig); - END $$ - SQL diff --git a/testing/kuttl/e2e-other/exporter-upgrade/02--update-cluster.yaml b/testing/kuttl/e2e-other/exporter-upgrade/02--update-cluster.yaml deleted file mode 100644 index cde17d80b4..0000000000 --- a/testing/kuttl/e2e-other/exporter-upgrade/02--update-cluster.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: exporter -spec: - postgresVersion: 14 - image: registry.developers.crunchydata.com/crunchydata/crunchy-postgres:ubi8-14.5-1 diff --git a/testing/kuttl/e2e-other/exporter-upgrade/02-assert.yaml b/testing/kuttl/e2e-other/exporter-upgrade/02-assert.yaml deleted file mode 100644 index 9ad238b944..0000000000 --- a/testing/kuttl/e2e-other/exporter-upgrade/02-assert.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: exporter -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: batch/v1 -kind: Job -metadata: - labels: - postgres-operator.crunchydata.com/cluster: exporter - postgres-operator.crunchydata.com/pgbackrest-backup: replica-create -status: - succeeded: 1 ---- -apiVersion: v1 -kind: Service -metadata: - name: exporter-primary diff --git a/testing/kuttl/e2e-other/exporter-upgrade/03--check-exporter.yaml b/testing/kuttl/e2e-other/exporter-upgrade/03--check-exporter.yaml deleted file mode 100644 index 8161e463fc..0000000000 --- a/testing/kuttl/e2e-other/exporter-upgrade/03--check-exporter.yaml +++ /dev/null @@ -1,21 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - PRIMARY=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=exporter, - postgres-operator.crunchydata.com/role=master' - ) - - # Get errors from the exporter - # See the README.md for a discussion of these errors - ERR=$(kubectl logs --namespace "${NAMESPACE}" "${PRIMARY}" -c exporter | grep -e "Error running query on database") - ERR_COUNT=$(echo "$ERR" | wc -l) - - if [[ "$ERR_COUNT" -gt 2 ]]; then - echo "Errors in log from exporter: ${ERR}" - exit 1 - fi diff --git a/testing/kuttl/e2e-other/exporter-upgrade/README.md b/testing/kuttl/e2e-other/exporter-upgrade/README.md deleted file mode 100644 index fefe28a95c..0000000000 --- a/testing/kuttl/e2e-other/exporter-upgrade/README.md +++ /dev/null @@ -1,31 +0,0 @@ -The exporter-upgrade test makes sure that PGO updates an extension used for monitoring. This -avoids an error where a user might update to a new PG image with a newer extension, but with an -older extension operative. - -Note: This test relies on two `crunchy-postgres` images with known, different `pgnodemx` extensions: -the image created in 00--cluster.yaml has `pgnodemx` 1.1; the image we update the cluster to in -02--update-cluster.yaml has `pgnodemx` 1.3. - -00-01 -This starts up a cluster with a purposely outdated `pgnodemx` extension. Because we want a specific -extension, the image used here is hard-coded (and so outdated it's not publicly available). - -(This image is so outdated that it doesn't finish creating a backup with the current PGO, which is -why the 00-assert.yaml only checks that the pod is ready; and why 01--check-exporter.yaml wraps the -call in a retry loop.) - -02-03 -The cluster is updated with a newer (and hardcoded) image with a newer version of `pgnodemx`. Due -to the change made in https://github.com/CrunchyData/postgres-operator/pull/3400, this should no -longer produce multiple errors. - -Note: a few errors may be logged after the `exporter` container attempts to run the `pgnodemx` -functions but before the extension is updated. So this checks that there are no more than 2 errors, -since that was the observed maximum number of printed errors during manual tests of the check. - -For instance, using these hardcoded images (with `pgnodemx` versions 1.1 and 1.3), those errors were: - -``` -Error running query on database \"localhost:5432\": ccp_nodemx_disk_activity pq: query-specified return tuple and function return type are not compatible" -Error running query on database \"localhost:5432\": ccp_nodemx_data_disk pq: query-specified return tuple and function return type are not compatible -``` diff --git a/testing/kuttl/e2e-other/gssapi/00-assert.yaml b/testing/kuttl/e2e-other/gssapi/00-assert.yaml deleted file mode 100644 index ea828be0c4..0000000000 --- a/testing/kuttl/e2e-other/gssapi/00-assert.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: krb5 ---- -apiVersion: v1 -kind: Secret -metadata: - name: krb5-keytab diff --git a/testing/kuttl/e2e-other/gssapi/00-krb5-keytab.yaml b/testing/kuttl/e2e-other/gssapi/00-krb5-keytab.yaml deleted file mode 100644 index 6311193d55..0000000000 --- a/testing/kuttl/e2e-other/gssapi/00-krb5-keytab.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: -- command: kubectl exec -n krb5 -it krb5-kdc-0 -- /krb5-scripts/krb5.sh "${NAMESPACE}" diff --git a/testing/kuttl/e2e-other/gssapi/01-assert.yaml b/testing/kuttl/e2e-other/gssapi/01-assert.yaml deleted file mode 100644 index dbda953ead..0000000000 --- a/testing/kuttl/e2e-other/gssapi/01-assert.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: gssapi -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: v1 -kind: Service -metadata: - name: gssapi-primary diff --git a/testing/kuttl/e2e-other/gssapi/01-cluster.yaml b/testing/kuttl/e2e-other/gssapi/01-cluster.yaml deleted file mode 100644 index 8acfe46c4d..0000000000 --- a/testing/kuttl/e2e-other/gssapi/01-cluster.yaml +++ /dev/null @@ -1,41 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: gssapi -spec: - config: - files: - - secret: - name: krb5-keytab - - configMap: - name: krb5 - patroni: - dynamicConfiguration: - postgresql: - pg_hba: - - host postgres postgres 0.0.0.0/0 scram-sha-256 - - host all krb5hippo@PGO.CRUNCHYDATA.COM 0.0.0.0/0 gss - parameters: - krb_server_keyfile: /etc/postgres/krb5.keytab - users: - - name: postgres - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - name: instance1 - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi diff --git a/testing/kuttl/e2e-other/gssapi/02-assert.yaml b/testing/kuttl/e2e-other/gssapi/02-assert.yaml deleted file mode 100644 index 36f85d95d4..0000000000 --- a/testing/kuttl/e2e-other/gssapi/02-assert.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - name: psql-connect-gssapi -status: - succeeded: 1 diff --git a/testing/kuttl/e2e-other/gssapi/02-psql-connect.yaml b/testing/kuttl/e2e-other/gssapi/02-psql-connect.yaml deleted file mode 100644 index 30f02b3b19..0000000000 --- a/testing/kuttl/e2e-other/gssapi/02-psql-connect.yaml +++ /dev/null @@ -1,47 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - name: psql-connect-gssapi -spec: - backoffLimit: 6 - template: - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - command: - - bash - - -c - - -- - - |- - psql -c 'create user "krb5hippo@PGO.CRUNCHYDATA.COM";' - kinit -k -t /krb5-conf/krb5.keytab krb5hippo@PGO.CRUNCHYDATA.COM - psql -U krb5hippo@PGO.CRUNCHYDATA.COM -h gssapi-primary.$(NAMESPACE).svc.cluster.local -d postgres \ - -c 'select version();' - env: - - name: NAMESPACE - valueFrom: { fieldRef: { fieldPath: metadata.namespace } } - - name: PGHOST - valueFrom: { secretKeyRef: { name: gssapi-pguser-postgres, key: host } } - - name: PGPORT - valueFrom: { secretKeyRef: { name: gssapi-pguser-postgres, key: port } } - - name: PGUSER - valueFrom: { secretKeyRef: { name: gssapi-pguser-postgres, key: user } } - - name: PGPASSWORD - valueFrom: { secretKeyRef: { name: gssapi-pguser-postgres, key: password } } - - name: PGDATABASE - value: postgres - - name: KRB5_CONFIG - value: /krb5-conf/krb5.conf - volumeMounts: - - name: krb5-conf - mountPath: /krb5-conf - volumes: - - name: krb5-conf - projected: - sources: - - configMap: - name: krb5 - - secret: - name: krb5-keytab diff --git a/testing/kuttl/e2e-other/gssapi/README.md b/testing/kuttl/e2e-other/gssapi/README.md deleted file mode 100644 index 72d8d2b997..0000000000 --- a/testing/kuttl/e2e-other/gssapi/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# GSSAPI Authentication - -This test verifies that it is possible to properly configure PostgreSQL for GSSAPI -authentication. This is done by configuring a PostgresCluster for GSSAPI authentication, -and then utilizing a Kerberos ticket that has been issued by a Kerberos KDC server to log into -PostgreSQL. - -## Assumptions - -- A Kerberos Key Distribution Center (KDC) Pod named `krb5-kdc-0` is deployed inside of a `krb5` -namespace within the Kubernetes cluster -- The KDC server (`krb5-kdc-0`) contains a `/krb5-conf/krb5.sh` script that can be run as part -of the test to create the Kerberos principals, keytab secret and client configuration needed to -successfully run the test diff --git a/testing/kuttl/e2e-other/postgis-cluster/00--cluster.yaml b/testing/kuttl/e2e-other/postgis-cluster/00--cluster.yaml deleted file mode 100644 index 8dc88788bc..0000000000 --- a/testing/kuttl/e2e-other/postgis-cluster/00--cluster.yaml +++ /dev/null @@ -1,26 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: postgis -spec: - postgresVersion: ${KUTTL_PG_VERSION} - postGISVersion: "${KUTTL_POSTGIS_VERSION}" - instances: - - name: instance1 - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi diff --git a/testing/kuttl/e2e-other/postgis-cluster/00-assert.yaml b/testing/kuttl/e2e-other/postgis-cluster/00-assert.yaml deleted file mode 100644 index b0bda7753f..0000000000 --- a/testing/kuttl/e2e-other/postgis-cluster/00-assert.yaml +++ /dev/null @@ -1,24 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: postgis -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: batch/v1 -kind: Job -metadata: - labels: - postgres-operator.crunchydata.com/cluster: postgis - postgres-operator.crunchydata.com/pgbackrest-backup: replica-create -status: - succeeded: 1 ---- -apiVersion: v1 -kind: Service -metadata: - name: postgis-primary diff --git a/testing/kuttl/e2e-other/postgis-cluster/01--psql-connect.yaml b/testing/kuttl/e2e-other/postgis-cluster/01--psql-connect.yaml deleted file mode 100644 index 814958a9f6..0000000000 --- a/testing/kuttl/e2e-other/postgis-cluster/01--psql-connect.yaml +++ /dev/null @@ -1,132 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - name: psql-postgis-connect -spec: - backoffLimit: 6 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - name: PGHOST - valueFrom: { secretKeyRef: { name: postgis-pguser-postgis, key: host } } - - name: PGPORT - valueFrom: { secretKeyRef: { name: postgis-pguser-postgis, key: port } } - - name: PGDATABASE - valueFrom: { secretKeyRef: { name: postgis-pguser-postgis, key: dbname } } - - name: PGUSER - valueFrom: { secretKeyRef: { name: postgis-pguser-postgis, key: user } } - - name: PGPASSWORD - valueFrom: { secretKeyRef: { name: postgis-pguser-postgis, key: password } } - - { name: GIS_VERSION, value: "${KUTTL_POSTGIS_VERSION}" } - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - command: - - bash - - -c - - | - # Ensure PostGIS version is set - GIS_VERSION=${KUTTL_POSTGIS_VERSION} - GIS_VERSION=${GIS_VERSION:-notset} - - # check version - RESULT=$(psql -c "DO \$\$ - DECLARE - result boolean; - BEGIN - SELECT postgis_version() LIKE '%${GIS_VERSION}%' INTO result; - ASSERT result = 't', 'PostGIS version incorrect'; - END \$\$;" 2>&1) - - if [[ "$RESULT" == *"ERROR"* ]]; then - echo "$RESULT" - exit 1 - fi - - # check full version - RESULT=$(psql -c "DO \$\$ - DECLARE - result boolean; - BEGIN - SELECT postgis_full_version() LIKE 'POSTGIS=\"%${GIS_VERSION}%' INTO result; - ASSERT result = 't', 'PostGIS full version incorrect'; - END \$\$;" 2>&1) - - if [[ "$RESULT" == *"ERROR"* ]]; then - echo "$RESULT" - exit 1 - fi - - # check expected schemas (tiger, tiger_data and topology) - # - https://www.postgresql.org/docs/current/catalog-pg-namespace.html - RESULT=$(psql -c "DO \$\$ - DECLARE - result text; - BEGIN - SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname='tiger' INTO result; - ASSERT result = 'tiger', 'PostGIS tiger schema missing'; - END \$\$;" 2>&1) - - if [[ "$RESULT" == *"ERROR"* ]]; then - echo "$RESULT" - exit 1 - fi - - RESULT=$(psql -c "DO \$\$ - DECLARE - result text; - BEGIN - SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname='tiger_data' INTO result; - ASSERT result = 'tiger_data', 'PostGIS tiger_data schema missing'; - END \$\$;" 2>&1) - - if [[ "$RESULT" == *"ERROR"* ]]; then - echo "$RESULT" - exit 1 - fi - - RESULT=$(psql -c "DO \$\$ - DECLARE - result text; - BEGIN - SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname='topology' INTO result; - ASSERT result = 'topology', 'PostGIS topology schema missing'; - END \$\$;" 2>&1) - - if [[ "$RESULT" == *"ERROR"* ]]; then - echo "$RESULT" - exit 1 - fi - - # check point creation - RESULT=$(psql -c "DO \$\$ - DECLARE - result text; - BEGIN - SELECT pg_typeof(ST_MakePoint(28.385200,-81.563900)) INTO result; - ASSERT result = 'geometry', 'Unable to create PostGIS point'; - END \$\$;" 2>&1) - - if [[ "$RESULT" == *"ERROR"* ]]; then - echo "$RESULT" - exit 1 - fi - - # check GeoJSON function - RESULT=$(psql -c "DO \$\$ - DECLARE - result text; - BEGIN - SELECT ST_AsGeoJSON('SRID=4326;POINT(-118.4079 33.9434)'::geography) INTO result; - ASSERT result = '{\"type\":\"Point\",\"coordinates\":[-118.4079,33.9434]}', FORMAT('GeoJSON check failed, got %L', result); - END \$\$;" 2>&1) - - if [[ "$RESULT" == *"ERROR"* ]]; then - echo "$RESULT" - exit 1 - fi diff --git a/testing/kuttl/e2e-other/postgis-cluster/01-assert.yaml b/testing/kuttl/e2e-other/postgis-cluster/01-assert.yaml deleted file mode 100644 index 22e9e6f9de..0000000000 --- a/testing/kuttl/e2e-other/postgis-cluster/01-assert.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: batch/v1 -kind: Job -metadata: - name: psql-postgis-connect -status: - succeeded: 1 diff --git a/testing/kuttl/e2e-other/replica-service/01-node-port.yaml b/testing/kuttl/e2e-other/replica-service/01-node-port.yaml deleted file mode 100644 index c80e947e40..0000000000 --- a/testing/kuttl/e2e-other/replica-service/01-node-port.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/np-cluster.yaml -assert: -- files/np-check.yaml diff --git a/testing/kuttl/e2e-other/replica-service/02-loadbalancer.yaml b/testing/kuttl/e2e-other/replica-service/02-loadbalancer.yaml deleted file mode 100644 index f1433111db..0000000000 --- a/testing/kuttl/e2e-other/replica-service/02-loadbalancer.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/lb-cluster.yaml -assert: -- files/lb-check.yaml diff --git a/testing/kuttl/e2e-other/replica-service/03-cluster-ip.yaml b/testing/kuttl/e2e-other/replica-service/03-cluster-ip.yaml deleted file mode 100644 index de6055ea6b..0000000000 --- a/testing/kuttl/e2e-other/replica-service/03-cluster-ip.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/cip-cluster.yaml -assert: -- files/cip-check.yaml diff --git a/testing/kuttl/e2e-other/replica-service/files/base-check.yaml b/testing/kuttl/e2e-other/replica-service/files/base-check.yaml deleted file mode 100644 index a83fce0f57..0000000000 --- a/testing/kuttl/e2e-other/replica-service/files/base-check.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: service -status: - instances: - - name: instance1 - readyReplicas: 2 - replicas: 2 - updatedReplicas: 2 ---- -apiVersion: v1 -kind: Service -metadata: - name: service-replicas diff --git a/testing/kuttl/e2e-other/replica-service/files/base-cluster.yaml b/testing/kuttl/e2e-other/replica-service/files/base-cluster.yaml deleted file mode 100644 index 67c4481d2f..0000000000 --- a/testing/kuttl/e2e-other/replica-service/files/base-cluster.yaml +++ /dev/null @@ -1,28 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: service -spec: - postgresVersion: ${KUTTL_PG_VERSION} - replicaService: - type: ClusterIP - instances: - - name: instance1 - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 0.5Gi - replicas: 2 - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 0.5Gi diff --git a/testing/kuttl/e2e-other/replica-service/files/cip-check.yaml b/testing/kuttl/e2e-other/replica-service/files/cip-check.yaml deleted file mode 100644 index 5bf5422bb8..0000000000 --- a/testing/kuttl/e2e-other/replica-service/files/cip-check.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: service-replicas -spec: - type: ClusterIP - selector: - postgres-operator.crunchydata.com/cluster: service - postgres-operator.crunchydata.com/role: replica diff --git a/testing/kuttl/e2e-other/replica-service/files/cip-cluster.yaml b/testing/kuttl/e2e-other/replica-service/files/cip-cluster.yaml deleted file mode 100644 index 8545aa8223..0000000000 --- a/testing/kuttl/e2e-other/replica-service/files/cip-cluster.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: service -spec: - replicaService: - type: ClusterIP - nodePort: null diff --git a/testing/kuttl/e2e-other/replica-service/files/lb-check.yaml b/testing/kuttl/e2e-other/replica-service/files/lb-check.yaml deleted file mode 100644 index b8519491c7..0000000000 --- a/testing/kuttl/e2e-other/replica-service/files/lb-check.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: service-replicas -spec: - type: LoadBalancer - selector: - postgres-operator.crunchydata.com/cluster: service - postgres-operator.crunchydata.com/role: replica diff --git a/testing/kuttl/e2e-other/replica-service/files/lb-cluster.yaml b/testing/kuttl/e2e-other/replica-service/files/lb-cluster.yaml deleted file mode 100644 index 5e18f71dcd..0000000000 --- a/testing/kuttl/e2e-other/replica-service/files/lb-cluster.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: service -spec: - replicaService: - type: LoadBalancer - nodePort: null diff --git a/testing/kuttl/e2e-other/replica-service/files/np-check.yaml b/testing/kuttl/e2e-other/replica-service/files/np-check.yaml deleted file mode 100644 index c7d791e36a..0000000000 --- a/testing/kuttl/e2e-other/replica-service/files/np-check.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: service-replicas -spec: - type: NodePort - ports: - - name: postgres - port: 5432 - protocol: TCP - targetPort: postgres - selector: - postgres-operator.crunchydata.com/cluster: service - postgres-operator.crunchydata.com/role: replica diff --git a/testing/kuttl/e2e-other/replica-service/files/np-cluster.yaml b/testing/kuttl/e2e-other/replica-service/files/np-cluster.yaml deleted file mode 100644 index 0b20ae63ad..0000000000 --- a/testing/kuttl/e2e-other/replica-service/files/np-cluster.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: service -spec: - replicaService: - type: NodePort diff --git a/testing/kuttl/e2e-other/resize-volume/00-assert.yaml b/testing/kuttl/e2e-other/resize-volume/00-assert.yaml deleted file mode 100644 index b4372b75e7..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/00-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# Ensure that the default StorageClass supports VolumeExpansion -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - annotations: - storageclass.kubernetes.io/is-default-class: "true" -allowVolumeExpansion: true diff --git a/testing/kuttl/e2e-other/resize-volume/01-assert.yaml b/testing/kuttl/e2e-other/resize-volume/01-assert.yaml deleted file mode 100644 index ea72af469c..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/01-assert.yaml +++ /dev/null @@ -1,59 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: resize-volume-up -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: batch/v1 -kind: Job -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-up - postgres-operator.crunchydata.com/pgbackrest-backup: replica-create -status: - succeeded: 1 ---- -apiVersion: v1 -kind: Service -metadata: - name: resize-volume-up-primary ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-up - postgres-operator.crunchydata.com/instance-set: instance1 -spec: - resources: - requests: - storage: 1Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 1Gi - phase: Bound ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-up - postgres-operator.crunchydata.com/data: pgbackrest - postgres-operator.crunchydata.com/pgbackrest-repo: repo1 -spec: - resources: - requests: - storage: 1Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 1Gi - phase: Bound diff --git a/testing/kuttl/e2e-other/resize-volume/02--create-data.yaml b/testing/kuttl/e2e-other/resize-volume/02--create-data.yaml deleted file mode 100644 index c41a6f80c4..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/02--create-data.yaml +++ /dev/null @@ -1,31 +0,0 @@ ---- -# Create some data that should be present after resizing. -apiVersion: batch/v1 -kind: Job -metadata: - name: create-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - name: PGURI - valueFrom: { secretKeyRef: { name: resize-volume-up-pguser-resize-volume-up, key: uri } } - - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - command: - - psql - - $(PGURI) - - --set=ON_ERROR_STOP=1 - - --command - - | - CREATE TABLE important (data) AS VALUES ('treasure'); diff --git a/testing/kuttl/e2e-other/resize-volume/02-assert.yaml b/testing/kuttl/e2e-other/resize-volume/02-assert.yaml deleted file mode 100644 index fdb42e68f5..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/02-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: create-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e-other/resize-volume/03--resize.yaml b/testing/kuttl/e2e-other/resize-volume/03--resize.yaml deleted file mode 100644 index dd7c96901f..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/03--resize.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: resize-volume-up -spec: - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - name: instance1 - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 2Gi - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 2Gi diff --git a/testing/kuttl/e2e-other/resize-volume/03-assert.yaml b/testing/kuttl/e2e-other/resize-volume/03-assert.yaml deleted file mode 100644 index 11aa230cd4..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/03-assert.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# We know that the PVC sizes have change so now we can check that they have been -# updated to have the expected size ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-up - postgres-operator.crunchydata.com/instance-set: instance1 -spec: - resources: - requests: - storage: 2Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 2Gi - phase: Bound ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-up - postgres-operator.crunchydata.com/data: pgbackrest - postgres-operator.crunchydata.com/pgbackrest-repo: repo1 -spec: - resources: - requests: - storage: 2Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 2Gi - phase: Bound diff --git a/testing/kuttl/e2e-other/resize-volume/06--check-data.yaml b/testing/kuttl/e2e-other/resize-volume/06--check-data.yaml deleted file mode 100644 index 682a46ef4d..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/06--check-data.yaml +++ /dev/null @@ -1,40 +0,0 @@ ---- -# Confirm that all the data still exists. -apiVersion: batch/v1 -kind: Job -metadata: - name: check-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - name: PGURI - valueFrom: { secretKeyRef: { name: resize-volume-up-pguser-resize-volume-up, key: uri } } - - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - # Confirm that all the data still exists. - # Note: the `$$$$` is reduced to `$$` by Kubernetes. - # - https://kubernetes.io/docs/tasks/inject-data-application/ - command: - - psql - - $(PGURI) - - --set=ON_ERROR_STOP=1 - - --command - - | - DO $$$$ - DECLARE - keep_data jsonb; - BEGIN - SELECT jsonb_agg(important) INTO keep_data FROM important; - ASSERT keep_data = '[{"data":"treasure"}]', format('got %L', keep_data); - END $$$$; diff --git a/testing/kuttl/e2e-other/resize-volume/06-assert.yaml b/testing/kuttl/e2e-other/resize-volume/06-assert.yaml deleted file mode 100644 index cf743b8701..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/06-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: check-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e-other/resize-volume/11--cluster.yaml b/testing/kuttl/e2e-other/resize-volume/11--cluster.yaml deleted file mode 100644 index 8d2d602ca6..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/11--cluster.yaml +++ /dev/null @@ -1,25 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: resize-volume-down -spec: - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - name: instance1 - dataVolumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 2Gi - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 2Gi diff --git a/testing/kuttl/e2e-other/resize-volume/11-assert.yaml b/testing/kuttl/e2e-other/resize-volume/11-assert.yaml deleted file mode 100644 index 666b4a85c7..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/11-assert.yaml +++ /dev/null @@ -1,59 +0,0 @@ -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: resize-volume-down -status: - instances: - - name: instance1 - readyReplicas: 1 - replicas: 1 - updatedReplicas: 1 ---- -apiVersion: batch/v1 -kind: Job -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-down - postgres-operator.crunchydata.com/pgbackrest-backup: replica-create -status: - succeeded: 1 ---- -apiVersion: v1 -kind: Service -metadata: - name: resize-volume-down-primary ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-down - postgres-operator.crunchydata.com/instance-set: instance1 -spec: - resources: - requests: - storage: 2Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 2Gi - phase: Bound ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-down - postgres-operator.crunchydata.com/data: pgbackrest - postgres-operator.crunchydata.com/pgbackrest-repo: repo1 -spec: - resources: - requests: - storage: 2Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 2Gi - phase: Bound diff --git a/testing/kuttl/e2e-other/resize-volume/13-assert.yaml b/testing/kuttl/e2e-other/resize-volume/13-assert.yaml deleted file mode 100644 index 4210214fd6..0000000000 --- a/testing/kuttl/e2e-other/resize-volume/13-assert.yaml +++ /dev/null @@ -1,43 +0,0 @@ -apiVersion: v1 -kind: Event -type: Warning -involvedObject: - apiVersion: postgres-operator.crunchydata.com/v1beta1 - kind: PostgresCluster - name: resize-volume-down -reason: PersistentVolumeError ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-down - postgres-operator.crunchydata.com/instance-set: instance1 -spec: - resources: - requests: - storage: 2Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 2Gi - phase: Bound ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - labels: - postgres-operator.crunchydata.com/cluster: resize-volume-down - postgres-operator.crunchydata.com/data: pgbackrest - postgres-operator.crunchydata.com/pgbackrest-repo: repo1 -spec: - resources: - requests: - storage: 2Gi -status: - accessModes: - - ReadWriteOnce - capacity: - storage: 2Gi - phase: Bound diff --git a/testing/kuttl/e2e/exporter-custom-queries/README.md b/testing/kuttl/e2e/exporter-custom-queries/README.md index 801b6d02a8..6ec7462628 100644 --- a/testing/kuttl/e2e/exporter-custom-queries/README.md +++ b/testing/kuttl/e2e/exporter-custom-queries/README.md @@ -1,3 +1,3 @@ # Exporter -**Note**: This series of tests depends on PGO being deployed with the `AppendCustomQueries` feature gate OFF. There is a separate set of tests in `e2e-other` that tests the `AppendCustomQueries` functionality. +**Note**: This series of tests depends on PGO being deployed with the `AppendCustomQueries` feature gate OFF. diff --git a/testing/kuttl/e2e/major-upgrade-missing-image/10--cluster.yaml b/testing/kuttl/e2e/major-upgrade-missing-image/10--cluster.yaml index f5ef8c029e..8a0e57bab6 100644 --- a/testing/kuttl/e2e/major-upgrade-missing-image/10--cluster.yaml +++ b/testing/kuttl/e2e/major-upgrade-missing-image/10--cluster.yaml @@ -8,11 +8,9 @@ metadata: spec: # postgres version that is no longer available postgresVersion: 11 - patroni: - dynamicConfiguration: - postgresql: - parameters: - shared_preload_libraries: pgaudit, set_user, pg_stat_statements, pgnodemx, pg_cron + config: + parameters: + shared_preload_libraries: pgaudit, set_user, pg_stat_statements, pgnodemx, pg_cron instances: - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } backups: diff --git a/testing/kuttl/e2e/major-upgrade/01--invalid-pgupgrade.yaml b/testing/kuttl/e2e/major-upgrade/01--invalid-pgupgrade.yaml deleted file mode 100644 index ea90f5718a..0000000000 --- a/testing/kuttl/e2e/major-upgrade/01--invalid-pgupgrade.yaml +++ /dev/null @@ -1,10 +0,0 @@ ---- -# This pgupgrade is invalid and should get that condition (even with no cluster) -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PGUpgrade -metadata: - name: major-upgrade-do-it -spec: - fromPostgresVersion: ${KUTTL_PG_VERSION} - toPostgresVersion: ${KUTTL_PG_VERSION} - postgresClusterName: major-upgrade diff --git a/testing/kuttl/e2e/major-upgrade/01-assert.yaml b/testing/kuttl/e2e/major-upgrade/01-assert.yaml deleted file mode 100644 index f4cef66aa7..0000000000 --- a/testing/kuttl/e2e/major-upgrade/01-assert.yaml +++ /dev/null @@ -1,10 +0,0 @@ ---- -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PGUpgrade -metadata: - name: major-upgrade-do-it -status: - conditions: - - type: "Progressing" - status: "False" - reason: "PGUpgradeInvalid" diff --git a/testing/kuttl/e2e/major-upgrade/20--cluster-with-invalid-version.yaml b/testing/kuttl/e2e/major-upgrade/20--cluster-with-invalid-version.yaml deleted file mode 100644 index 8d73277292..0000000000 --- a/testing/kuttl/e2e/major-upgrade/20--cluster-with-invalid-version.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -# Create a cluster where the version does not match the pgupgrade's `from` -# TODO(benjaminjb): this isn't quite working out -# apiVersion: postgres-operator.crunchydata.com/v1beta1 -# kind: PostgresCluster -# metadata: -# name: major-upgrade -# spec: -# shutdown: true -# postgresVersion: ${KUTTL_PG_UPGRADE_TOO_EARLY_FROM_VERSION} -# instances: -# - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } -# backups: -# pgbackrest: -# repos: -# - name: repo1 -# volume: -# volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } diff --git a/testing/kuttl/e2e/major-upgrade/20-assert.yaml b/testing/kuttl/e2e/major-upgrade/20-assert.yaml deleted file mode 100644 index 2ea1486284..0000000000 --- a/testing/kuttl/e2e/major-upgrade/20-assert.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -# # pgupgrade should exit since the cluster is already at the requested version -# apiVersion: postgres-operator.crunchydata.com/v1beta1 -# kind: PGUpgrade -# metadata: -# name: major-upgrade-do-it -# status: -# conditions: -# - type: "Progressing" -# status: "False" -# reason: "PGUpgradeInvalidForCluster" diff --git a/testing/kuttl/e2e/major-upgrade/21-delete-cluster.yaml b/testing/kuttl/e2e/major-upgrade/21-delete-cluster.yaml deleted file mode 100644 index 535c6311a4..0000000000 --- a/testing/kuttl/e2e/major-upgrade/21-delete-cluster.yaml +++ /dev/null @@ -1,8 +0,0 @@ ---- -# # Delete the existing cluster. -# apiVersion: kuttl.dev/v1beta1 -# kind: TestStep -# delete: -# - apiVersion: postgres-operator.crunchydata.com/v1beta1 -# kind: PostgresCluster -# name: major-upgrade diff --git a/testing/kuttl/e2e/major-upgrade/30--cluster.yaml b/testing/kuttl/e2e/major-upgrade/30--cluster.yaml index 01e1ef6175..07546c384e 100644 --- a/testing/kuttl/e2e/major-upgrade/30--cluster.yaml +++ b/testing/kuttl/e2e/major-upgrade/30--cluster.yaml @@ -6,11 +6,9 @@ metadata: name: major-upgrade spec: postgresVersion: ${KUTTL_PG_UPGRADE_FROM_VERSION} - patroni: - dynamicConfiguration: - postgresql: - parameters: - shared_preload_libraries: pgaudit, set_user, pg_stat_statements, pgnodemx, pg_cron + config: + parameters: + shared_preload_libraries: pgaudit, set_user, pg_stat_statements, pgnodemx, pg_cron instances: - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } replicas: 3 diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/00--cluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/00--cluster.yaml new file mode 100644 index 0000000000..5957e0fed6 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/00--cluster.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/00--create-cluster.yaml +assert: +- files/00-cluster-created.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/01--add-instrumentation-to-postgrescluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/01--add-instrumentation-to-postgrescluster.yaml new file mode 100644 index 0000000000..ddf7a754b4 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/01--add-instrumentation-to-postgrescluster.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/01--add-instrumentation.yaml +assert: +- files/01-instrumentation-added.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/02-assert-repo-host-does-not-logs.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/02-assert-repo-host-does-not-logs.yaml new file mode 100644 index 0000000000..31c077d540 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/02-assert-repo-host-does-not-logs.yaml @@ -0,0 +1,28 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the repo host pod are ready. +# Then, ensure that the collector logs for the repo-host do not contain any +# pgbackrest logs as the backup completed before the collector started up and we +# have the collector configured to only ingest new log records on start up. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/data=pgbackrest) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + logs=$(kubectl logs "${pod}" --namespace "${NAMESPACE}" -c collector | grep InstrumentationScope) + { !(contains "${logs}" 'InstrumentationScope pgbackrest') } || { + retry "pgbackrest logs were found when we did not expect any" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/03--backup.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/03--backup.yaml new file mode 100644 index 0000000000..95daf31a6a --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/03--backup.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/03--annotate-cluster.yaml +assert: +- files/03-backup-completed.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/04-assert-repo-host-contains-logs.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/04-assert-repo-host-contains-logs.yaml new file mode 100644 index 0000000000..a6cb86fb22 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/04-assert-repo-host-contains-logs.yaml @@ -0,0 +1,26 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the repo host pod are ready. +# Then, ensure that the repo-host collector logs have pgbackrest logs. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/data=pgbackrest) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + logs=$(kubectl logs "${pod}" --namespace "${NAMESPACE}" -c collector | grep InstrumentationScope) + { contains "${logs}" 'InstrumentationScope pgbackrest'; } || { + retry "pgbackrest logs were not found" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/05-assert-pgbouncer.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/05-assert-pgbouncer.yaml new file mode 100644 index 0000000000..87188b6f62 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/05-assert-pgbouncer.yaml @@ -0,0 +1,34 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the pgbouncer pod are ready. +# Then, scrape the collector metrics and check that pgbouncer metrics are present. +# Then, check the collector logs for pgbouncer logs. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/role=pgbouncer) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + scrape_metrics=$(kubectl exec "${pod}" -c collector -n "${NAMESPACE}" -- \ + curl --insecure --silent http://localhost:9187/metrics) + { contains "${scrape_metrics}" 'ccp_pgbouncer_clients_wait_seconds'; } || { + retry "pgbouncer metric not found" + exit 1 + } + + logs=$(kubectl logs "${pod}" --namespace "${NAMESPACE}" -c collector | grep InstrumentationScope) + { contains "${logs}" 'InstrumentationScope pgbouncer'; } || { + retry "pgbouncer logs not found" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/06-assert-instance.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/06-assert-instance.yaml new file mode 100644 index 0000000000..096c024d89 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/06-assert-instance.yaml @@ -0,0 +1,64 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the instance pod are ready. +# Then, grab the collector metrics output and check that a metric from both 5m +# and 5s queries are present, as well as patroni metrics. +# Then, check the collector logs for patroni, pgbackrest, and postgres logs. +# Finally, ensure the monitoring user exists and is configured. +- timeout: 400 + script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/data=postgres) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + logs=$(kubectl logs "${pod}" --namespace "${NAMESPACE}" -c collector | grep InstrumentationScope) + { contains "${logs}" 'InstrumentationScope patroni'; } || { + retry "patroni logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope pgbackrest'; } || { + retry "pgbackrest logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope postgres'; } || { + retry "postgres logs not found" + exit 1 + } + + scrape_metrics=$(kubectl exec "${pod}" -c collector -n "${NAMESPACE}" -- \ + curl --insecure --silent http://localhost:9187/metrics) + { contains "${scrape_metrics}" 'ccp_connection_stats_active'; } || { + retry "5 second metric not found" + exit 1 + } + { contains "${scrape_metrics}" 'patroni_postgres_running'; } || { + retry "patroni metric not found" + exit 1 + } + { contains "${scrape_metrics}" 'ccp_database_size_bytes'; } || { + retry "5 minute metric not found" + exit 1 + } + + kubectl exec --stdin "${pod}" --namespace "${NAMESPACE}" -c database \ + -- psql -qb --set ON_ERROR_STOP=1 --file=- <<'SQL' + DO $$ + DECLARE + result record; + BEGIN + SELECT * INTO result FROM pg_catalog.pg_roles WHERE rolname = 'ccp_monitoring'; + ASSERT FOUND, 'user not found'; + END $$ + SQL diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/07--add-instrumentation-to-pgadmin.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/07--add-instrumentation-to-pgadmin.yaml new file mode 100644 index 0000000000..55f2179939 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/07--add-instrumentation-to-pgadmin.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/07--add-instrumentation.yaml +assert: +- files/07-instrumentation-added.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/08-assert-pgadmin.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/08-assert-pgadmin.yaml new file mode 100644 index 0000000000..71434397e1 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/08-assert-pgadmin.yaml @@ -0,0 +1,30 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the pgadmin pod are ready. +# Then, check the collector logs for pgadmin and gunicorn logs. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/pgadmin=otel-pgadmin) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + logs=$(kubectl logs "${pod}" --namespace "${NAMESPACE}" -c collector | grep InstrumentationScope) + { contains "${logs}" 'InstrumentationScope pgadmin'; } || { + retry "pgadmin logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope gunicorn.access'; } || { + retry "gunicorn logs not found" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/09--add-custom-queries.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/09--add-custom-queries.yaml new file mode 100644 index 0000000000..223b1d71a8 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/09--add-custom-queries.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/09--add-custom-queries.yaml +assert: +- files/09-custom-queries-added.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/10-assert-custom-queries.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/10-assert-custom-queries.yaml new file mode 100644 index 0000000000..9476bb564e --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/10-assert-custom-queries.yaml @@ -0,0 +1,41 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the instance pod are ready. +# Then, grab the collector metrics output and check that the two metrics that we +# checked for earlier are no longer there. +# Then, check that the two custom metrics that we added are present. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/data=postgres) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + scrape_metrics=$(kubectl exec "${pod}" -c collector -n "${NAMESPACE}" -- \ + curl --insecure --silent http://localhost:9187/metrics) + { !(contains "${scrape_metrics}" 'ccp_connection_stats_active') } || { + retry "5 second metric still present" + exit 1 + } + { !(contains "${scrape_metrics}" 'ccp_database_size_bytes') } || { + retry "5 minute metric still present" + exit 1 + } + { contains "${scrape_metrics}" 'custom_table_count'; } || { + retry "fast custom metric not found" + exit 1 + } + { contains "${scrape_metrics}" 'custom_pg_stat_statements_row_count'; } || { + retry "slow custom metric not found" + exit 1 + } diff --git a/testing/kuttl/e2e-other/exporter-standby/00--create-certs.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/11--add-per-db-metrics-to-postgrescluster.yaml similarity index 59% rename from testing/kuttl/e2e-other/exporter-standby/00--create-certs.yaml rename to testing/kuttl/e2e/otel-logging-and-metrics/11--add-per-db-metrics-to-postgrescluster.yaml index 9c9cd140ac..8e73e1874e 100644 --- a/testing/kuttl/e2e-other/exporter-standby/00--create-certs.yaml +++ b/testing/kuttl/e2e/otel-logging-and-metrics/11--add-per-db-metrics-to-postgrescluster.yaml @@ -1,4 +1,4 @@ apiVersion: kuttl.dev/v1beta1 kind: TestStep apply: -- files/cluster-certs.yaml +- files/11--add-per-db-metrics.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/12-assert-per-db-queries.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/12-assert-per-db-queries.yaml new file mode 100644 index 0000000000..10a8645b32 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/12-assert-per-db-queries.yaml @@ -0,0 +1,32 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the instance pod are ready. +# Then, grab the collector metrics output and check that the per-db metrics +# are present for the single added target. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/data=postgres) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + scrape_metrics=$(kubectl exec "${pod}" -c collector -n "${NAMESPACE}" -- \ + curl --insecure --silent http://localhost:9187/metrics) + { contains "${scrape_metrics}" 'ccp_table_size_bytes{dbname="pikachu"'; } || { + retry "ccp_table_size_bytes not found for pikachu" + exit 1 + } + { ! contains "${scrape_metrics}" 'ccp_table_size_bytes{dbname="onix"'; } || { + retry "ccp_table_size_bytes found for onix" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/13--add-second-per-db-metrics-to-postgrescluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/13--add-second-per-db-metrics-to-postgrescluster.yaml new file mode 100644 index 0000000000..12791e5066 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/13--add-second-per-db-metrics-to-postgrescluster.yaml @@ -0,0 +1,4 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/13--add-per-db-metrics.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/14-assert-per-db-queries-for-multiple-targets.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/14-assert-per-db-queries-for-multiple-targets.yaml new file mode 100644 index 0000000000..bfbe2b1578 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/14-assert-per-db-queries-for-multiple-targets.yaml @@ -0,0 +1,32 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the instance pod are ready. +# Then, grab the collector metrics output and check that the per-db metrics +# are present for both added targets. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/data=postgres) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + scrape_metrics=$(kubectl exec "${pod}" -c collector -n "${NAMESPACE}" -- \ + curl --insecure --silent http://localhost:9187/metrics) + { contains "${scrape_metrics}" 'ccp_table_size_bytes{dbname="pikachu"'; } || { + retry "ccp_table_size_bytes not found for pikachu" + exit 1 + } + { contains "${scrape_metrics}" 'ccp_table_size_bytes{dbname="onix"'; } || { + retry "ccp_table_size_bytes not found for onix" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/15--remove-per-db-metrics-from-postgrescluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/15--remove-per-db-metrics-from-postgrescluster.yaml new file mode 100644 index 0000000000..549f21d55e --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/15--remove-per-db-metrics-from-postgrescluster.yaml @@ -0,0 +1,4 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/15--remove-per-db-metrics.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/16-assert-per-db-query-removed.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/16-assert-per-db-query-removed.yaml new file mode 100644 index 0000000000..d75c06827d --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/16-assert-per-db-query-removed.yaml @@ -0,0 +1,32 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the instance pod are ready. +# Then, grab the collector metrics output and check that the per-db metrics +# are absent from the targets since they've been removed. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/data=postgres) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + scrape_metrics=$(kubectl exec "${pod}" -c collector -n "${NAMESPACE}" -- \ + curl --insecure --silent http://localhost:9187/metrics) + { ! contains "${scrape_metrics}" 'ccp_table_size_bytes{dbname="pikachu"'; } || { + retry "ccp_table_size_bytes found for pikachu" + exit 1 + } + { ! contains "${scrape_metrics}" 'ccp_table_size_bytes{dbname="onix"'; } || { + retry "ccp_table_size_bytes found for onix" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/17--add-custom-queries-per-db.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/17--add-custom-queries-per-db.yaml new file mode 100644 index 0000000000..e1b2ebfeb3 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/17--add-custom-queries-per-db.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/17--add-custom-queries-per-db.yaml +assert: +- files/17-custom-queries-per-db-added.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/18-assert-custom-queries-per-db.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/18-assert-custom-queries-per-db.yaml new file mode 100644 index 0000000000..a4631bf9e8 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/18-assert-custom-queries-per-db.yaml @@ -0,0 +1,42 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the instance pod are ready. +# Then, grab the collector metrics output and check that the two metrics that we +# checked for earlier are no longer there. +# Then, check that the two custom metrics that we added are present +# only for the targets that were specified. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster,postgres-operator.crunchydata.com/data=postgres) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + scrape_metrics=$(kubectl exec "${pod}" -c collector -n "${NAMESPACE}" -- \ + curl --insecure --silent http://localhost:9187/metrics) + { contains "${scrape_metrics}" 'ccp_table_size_bytes_1{dbname="pikachu"'; } || { + retry "custom metric not found for pikachu db" + exit 1 + } + { contains "${scrape_metrics}" 'ccp_table_size_bytes_1{dbname="onix"'; } || { + retry "custom metric found for onix db" + exit 1 + } + { contains "${scrape_metrics}" 'ccp_table_size_bytes_2{dbname="onix"'; } || { + retry "custom metric not found for onix db" + exit 1 + } + { ! contains "${scrape_metrics}" 'ccp_table_size_bytes_2{dbname="pikachu"'; } || { + retry "custom metric found for pikachu db" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/19--add-logs-metrics-exporter.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/19--add-logs-metrics-exporter.yaml new file mode 100644 index 0000000000..7b21e0ef50 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/19--add-logs-metrics-exporter.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/19--add-logs-metrics-exporter.yaml +assert: +- files/19-logs-metrics-exporter-added.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/20-assert-logs-metrics-exported.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/20-assert-logs-metrics-exported.yaml new file mode 100644 index 0000000000..2022397ce9 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/20-assert-logs-metrics-exported.yaml @@ -0,0 +1,52 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that the standalone otel-collector container is ready. +# Then, check the standalone collector logs for logs from all six potential +# sources: patroni, pgbackrest, postgres, pgbouncer, pgadmin, and gunicorn. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" -l app=opentelemetry) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + logs=$(kubectl logs "${pod}" --namespace "${NAMESPACE}" -c otel-collector | grep InstrumentationScope) + { contains "${logs}" 'InstrumentationScope patroni'; } || { + retry "patroni logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope pgbackrest'; } || { + retry "pgbackrest logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope postgres'; } || { + retry "postgres logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope pgbouncer'; } || { + retry "pgbouncer logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope pgadmin'; } || { + retry "pgadmin logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope gunicorn.access'; } || { + retry "gunicorn logs not found" + exit 1 + } + + metrics=$(kubectl logs "${pod}" --namespace "${NAMESPACE}" -c otel-collector | grep ccp) + { contains "${metrics}" 'ccp_stat'; } || { + retry "metrics not found" + exit 1 + } diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/21--cluster-no-backups.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/21--cluster-no-backups.yaml new file mode 100644 index 0000000000..a24e1c8f2d --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/21--cluster-no-backups.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +apply: +- files/21--create-cluster.yaml +assert: +- files/21-cluster-created.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/22-assert-instance.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/22-assert-instance.yaml new file mode 100644 index 0000000000..411c910486 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/22-assert-instance.yaml @@ -0,0 +1,55 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +commands: +# First, check that all containers in the instance pod are ready. +# Then, grab the collector metrics output and check that a postgres +# metric is present, as well as a patroni metric. +# Then, check the collector logs for patroni, and postgres logs. +# Finally, ensure the monitoring user exists and is configured. +- script: | + retry() { bash -ceu 'printf "$1\nSleeping...\n" && sleep 5' - "$@"; } + check_containers_ready() { bash -ceu 'echo "$1" | jq -e ".[] | select(.type==\"ContainersReady\") | .status==\"True\""' - "$@"; } + contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } + + pod=$(kubectl get pods -o name -n "${NAMESPACE}" \ + -l postgres-operator.crunchydata.com/cluster=otel-cluster-no-backups,postgres-operator.crunchydata.com/data=postgres) + [ "$pod" = "" ] && retry "Pod not found" && exit 1 + + condition_json=$(kubectl get "${pod}" -n "${NAMESPACE}" -o jsonpath="{.status.conditions}") + [ "$condition_json" = "" ] && retry "conditions not found" && exit 1 + { check_containers_ready "$condition_json"; } || { + retry "containers not ready" + exit 1 + } + + scrape_metrics=$(kubectl exec "${pod}" -c collector -n "${NAMESPACE}" -- \ + curl --insecure --silent http://localhost:9187/metrics) + { contains "${scrape_metrics}" 'ccp_connection_stats_active'; } || { + retry "5 second metric not found" + exit 1 + } + { contains "${scrape_metrics}" 'patroni_postgres_running'; } || { + retry "patroni metric not found" + exit 1 + } + + logs=$(kubectl logs "${pod}" --namespace "${NAMESPACE}" -c collector | grep InstrumentationScope) + { contains "${logs}" 'InstrumentationScope patroni'; } || { + retry "patroni logs not found" + exit 1 + } + { contains "${logs}" 'InstrumentationScope postgres'; } || { + retry "postgres logs not found" + exit 1 + } + + kubectl exec --stdin "${pod}" --namespace "${NAMESPACE}" -c database \ + -- psql -qb --set ON_ERROR_STOP=1 --file=- <<'SQL' + DO $$ + DECLARE + result record; + BEGIN + SELECT * INTO result FROM pg_catalog.pg_roles WHERE rolname = 'ccp_monitoring'; + ASSERT FOUND, 'user not found'; + END $$ + SQL diff --git a/testing/kuttl/e2e-other/replica-service/00-base-cluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/23--cluster-add-backups.yaml similarity index 50% rename from testing/kuttl/e2e-other/replica-service/00-base-cluster.yaml rename to testing/kuttl/e2e/otel-logging-and-metrics/23--cluster-add-backups.yaml index 725f40de14..52990e4372 100644 --- a/testing/kuttl/e2e-other/replica-service/00-base-cluster.yaml +++ b/testing/kuttl/e2e/otel-logging-and-metrics/23--cluster-add-backups.yaml @@ -1,6 +1,6 @@ apiVersion: kuttl.dev/v1beta1 kind: TestStep apply: -- files/base-cluster.yaml +- files/23--add-backups.yaml assert: -- files/base-check.yaml +- files/23-backups-added.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/24--remove-backups.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/24--remove-backups.yaml new file mode 100644 index 0000000000..abd64d40a9 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/24--remove-backups.yaml @@ -0,0 +1,6 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: +- command: |- + kubectl patch postgrescluster otel-cluster-no-backups --type 'merge' -p '{"spec":{"backups": null}}' + namespaced: true diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/25--annotate-cluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/25--annotate-cluster.yaml new file mode 100644 index 0000000000..d017479ca3 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/25--annotate-cluster.yaml @@ -0,0 +1,7 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: +- command: kubectl annotate postgrescluster otel-cluster-no-backups postgres-operator.crunchydata.com/authorizeBackupRemoval="true" + namespaced: true +assert: +- files/25-backups-removed.yaml diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/README.md b/testing/kuttl/e2e/otel-logging-and-metrics/README.md new file mode 100644 index 0000000000..46e3169e71 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/README.md @@ -0,0 +1,40 @@ +# Test OTel Logging and Metrics + +## Assumptions + +This test assumes that the operator has both OpenTelemetryLogs and OpenTelemetryMetrics feature gates turned on and that you are using an operator versioned 5.8 or greater. + +## Process + +1. Create a basic cluster with pgbouncer and pgadmin in place. (00) + 1. Ensure cluster comes up, that all containers are running and ready, and that the initial backup is complete. +2. Add the `instrumentation` spec to both PostgresCluster and PGAdmin manifests. (01-08) + 1. Ensure that OTel collector containers and `crunchy-otel-collector` labels are added to the four pods (postgres instance, repo-host, pgbouncer, & pgadmin) and that the collector containers are running and ready. + 2. Assert that the instance pod collector is getting postgres and patroni metrics and postgres, patroni, and pgbackrest logs. + 3. Assert that the pgbouncer pod collector is getting pgbouncer metrics and logs. + 4. Assert that the pgAdmin pod collector is getting pgAdmin and gunicorn logs. + 5. Assert that the repo-host pod collector is NOT getting pgbackrest logs. We do not expect logs yet as the initial backup completed and created a log file; however, we configure the collector to only ingest new logs after it has started up. + 6. Create a manual backup and ensure that it completes successfully. + 7. Ensure that the repo-host pod collector is now getting pgbackrest logs. +3. Add both "add" and "remove" custom queries to the PostgresCluster `instrumentation` spec and create a ConfigMap that holds the custom queries to add. (09-10) + 1. Ensure that the ConfigMap is created. + 2. Assert that the metrics that were removed (which we checked for earlier) are in fact no longer present in the collector metrics. + 3. Assert that the custom metrics that were added are present in the collector metrics. +4. Exercise per-db metric functionality by adding users, per-db targets, removing metrics from per-db defaults, adding custom metric db target. (11-18) + 1. Add users and per-db target, assert that per-db default metric is available for named target. + 2. Add second per-db target, assert that per-db default metric is available for all named targets. + 3. Remove per-db metric, assert that the per-db default metric is absent for all targets. + 4. Add custom metrics with a specified db, assert that we get that metric just for the specified target. +5. Add an `otlp` exporter to both PostgresCluster and PGAdmin `instrumentation` specs and create a standalone OTel collector to receive data from our sidecar collectors. (9-20) + 1. Ensure that the ConfigMap, Service, and Deployment for the standalone OTel collector come up and that the collector container is running and ready. + 2. Assert that the standalone collector is receiving logs from all of our components (i.e. the standalone collector is getting logs for postgres, patroni, pgbackrest, pgbouncer, pgadmin, and gunicorn). +6. Create a new cluster with `instrumentation` spec in place, but no `backups` spec to test the OTel features with optional backups. (21-25) + 1. Ensure that the cluster comes up and the database and collector containers are running and ready. + 2. Add a backups spec to the new cluster and ensure that pgbackrest is added to the instance pod, a repo-host pod is created, and the collector runs on both pods. + 3. Remove the backups spec from the new cluster. + 4. Annotate the cluster to allow backups to be removed. + 5. Ensure that the repo-host pod is destroyed, pgbackrest is removed from the instance pod, and the collector continues to run on the instance pod. + +### NOTES + +It is possible this test could flake if for some reason a component is not producing any logs. If we start to see this happen, we could either create some test steps that execute some actions that should trigger logs or turn up the log levels (although the latter option could create more problems as we have seen issues with the collector when the stream of logs is too voluminous). diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/00--create-cluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/00--create-cluster.yaml new file mode 100644 index 0000000000..3345bef5f9 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/00--create-cluster.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +spec: + postgresVersion: ${KUTTL_PG_VERSION} + instances: + - name: instance1 + dataVolumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + backups: + pgbackrest: + manual: + repoName: repo1 + options: + - --type=diff + repos: + - name: repo1 + volume: + volumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + config: + parameters: + log_min_messages: INFO + proxy: + pgBouncer: {} +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PGAdmin +metadata: + name: otel-pgadmin +spec: + users: + - username: otel@example.com + role: Administrator + passwordRef: + name: pgadmin-password-secret + key: otel-password + dataVolumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + serverGroups: + - name: supply + # An empty selector selects all postgresclusters in the Namespace + postgresClusterSelector: {} + config: + settings: + AUTHENTICATION_SOURCES: ['internal'] diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/00-cluster-created.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/00-cluster-created.yaml new file mode 100644 index 0000000000..97bd3e2b97 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/00-cluster-created.yaml @@ -0,0 +1,112 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 + proxy: + pgBouncer: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster +status: + containerStatuses: + - name: database + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgbackrest + postgres-operator.crunchydata.com/cluster: otel-cluster +status: + containerStatuses: + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + phase: Running +--- +apiVersion: batch/v1 +kind: Job +metadata: + labels: + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/pgbackrest-backup: replica-create +status: + succeeded: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/role: pgbouncer + postgres-operator.crunchydata.com/cluster: otel-cluster +status: + containerStatuses: + - name: pgbouncer + ready: true + started: true + - name: pgbouncer-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-primary +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgadmin + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +status: + containerStatuses: + - name: pgadmin + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Secret +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +type: Opaque diff --git a/testing/kuttl/e2e-other/cluster-migrate/05--cluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/01--add-instrumentation.yaml similarity index 72% rename from testing/kuttl/e2e-other/cluster-migrate/05--cluster.yaml rename to testing/kuttl/e2e/otel-logging-and-metrics/files/01--add-instrumentation.yaml index a81666ed01..ebde9f7caa 100644 --- a/testing/kuttl/e2e-other/cluster-migrate/05--cluster.yaml +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/01--add-instrumentation.yaml @@ -1,13 +1,9 @@ +--- apiVersion: postgres-operator.crunchydata.com/v1beta1 kind: PostgresCluster metadata: - name: cluster-migrate + name: otel-cluster spec: - dataSource: - volumes: - pgDataVolume: - pvcName: data-non-crunchy-cluster-0 - directory: data postgresVersion: ${KUTTL_PG_VERSION} instances: - name: instance1 @@ -19,6 +15,10 @@ spec: storage: 1Gi backups: pgbackrest: + manual: + repoName: repo1 + options: + - --type=diff repos: - name: repo1 volume: @@ -28,3 +28,9 @@ spec: resources: requests: storage: 1Gi + config: + parameters: + log_min_messages: INFO + proxy: + pgBouncer: {} + instrumentation: {} diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/01-instrumentation-added.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/01-instrumentation-added.yaml new file mode 100644 index 0000000000..672bdd2d1d --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/01-instrumentation-added.yaml @@ -0,0 +1,116 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 + proxy: + pgBouncer: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: database + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgbackrest + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/role: pgbouncer + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbouncer + ready: true + started: true + - name: pgbouncer-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-primary +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgadmin + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +status: + containerStatuses: + - name: pgadmin + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Secret +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +type: Opaque +--- diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/03--annotate-cluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/03--annotate-cluster.yaml new file mode 100644 index 0000000000..1133b7fe15 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/03--annotate-cluster.yaml @@ -0,0 +1,8 @@ +--- +# Annotate the cluster to trigger a backup. +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster + annotations: + postgres-operator.crunchydata.com/pgbackrest-backup: do-it diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/03-backup-completed.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/03-backup-completed.yaml new file mode 100644 index 0000000000..fed1f745b9 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/03-backup-completed.yaml @@ -0,0 +1,8 @@ +apiVersion: batch/v1 +kind: Job +metadata: + labels: + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/pgbackrest-backup: manual +status: + succeeded: 1 diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/07--add-instrumentation.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/07--add-instrumentation.yaml new file mode 100644 index 0000000000..166f0d3347 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/07--add-instrumentation.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PGAdmin +metadata: + name: otel-pgadmin +spec: + users: + - username: otel@example.com + role: Administrator + passwordRef: + name: pgadmin-password-secret + key: otel-password + dataVolumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + serverGroups: + - name: supply + # An empty selector selects all postgresclusters in the Namespace + postgresClusterSelector: {} + config: + settings: + AUTHENTICATION_SOURCES: ['internal'] + instrumentation: {} diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/07-instrumentation-added.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/07-instrumentation-added.yaml new file mode 100644 index 0000000000..858b78ff83 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/07-instrumentation-added.yaml @@ -0,0 +1,120 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 + proxy: + pgBouncer: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: database + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgbackrest + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/role: pgbouncer + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbouncer + ready: true + started: true + - name: pgbouncer-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-primary +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgadmin + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgadmin + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Secret +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +type: Opaque +--- diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/09--add-custom-queries.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/09--add-custom-queries.yaml new file mode 100644 index 0000000000..ed133fc26a --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/09--add-custom-queries.yaml @@ -0,0 +1,75 @@ +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +spec: + postgresVersion: ${KUTTL_PG_VERSION} + instances: + - name: instance1 + dataVolumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + backups: + pgbackrest: + manual: + repoName: repo1 + options: + - --type=diff + repos: + - name: repo1 + volume: + volumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + config: + parameters: + log_min_messages: INFO + proxy: + pgBouncer: {} + instrumentation: + metrics: + customQueries: + add: + - name: slow-custom-queries + queries: + name: my-custom-queries + key: my-slow-custom-queries.yaml + collectionInterval: 300s + - name: 2fast2furious + queries: + name: my-custom-queries + key: my-fast-custom-queries.yaml + remove: + - ccp_connection_stats_active + - ccp_database_size_bytes +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-custom-queries +data: + my-fast-custom-queries.yaml: | + - sql: > + SELECT count(*) FROM information_schema.tables; + metrics: + - metric_name: custom_table_count + value_column: count + description: Number of tables in the database + static_attributes: + server: "localhost:5432" + my-slow-custom-queries.yaml: | + - sql: > + SELECT count(*) FROM pg_stat_statements; + metrics: + - metric_name: custom_pg_stat_statements_row_count + value_column: count + description: Number of rows in pg_stat_statements + static_attributes: + server: "localhost:5432" diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/09-custom-queries-added.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/09-custom-queries-added.yaml new file mode 100644 index 0000000000..1a756b7a73 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/09-custom-queries-added.yaml @@ -0,0 +1,124 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 + proxy: + pgBouncer: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: database + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgbackrest + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/role: pgbouncer + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbouncer + ready: true + started: true + - name: pgbouncer-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-primary +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgadmin + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgadmin + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Secret +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +type: Opaque +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-custom-queries diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/11--add-per-db-metrics.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/11--add-per-db-metrics.yaml new file mode 100644 index 0000000000..1cf4c28a83 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/11--add-per-db-metrics.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +spec: + users: + - name: ash + databases: + - pikachu + - name: brock + databases: + - onix + instrumentation: + metrics: + perDBMetricTargets: + - pikachu diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/13--add-per-db-metrics.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/13--add-per-db-metrics.yaml new file mode 100644 index 0000000000..c383238be9 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/13--add-per-db-metrics.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +spec: + instrumentation: + metrics: + perDBMetricTargets: + - pikachu + - onix diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/15--remove-per-db-metrics.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/15--remove-per-db-metrics.yaml new file mode 100644 index 0000000000..4421de8482 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/15--remove-per-db-metrics.yaml @@ -0,0 +1,13 @@ +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +spec: + instrumentation: + metrics: + customQueries: + remove: + - ccp_connection_stats_active + - ccp_database_size_bytes + - ccp_table_size_bytes diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/17--add-custom-queries-per-db.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/17--add-custom-queries-per-db.yaml new file mode 100644 index 0000000000..92360a4a9a --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/17--add-custom-queries-per-db.yaml @@ -0,0 +1,62 @@ +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +spec: + instrumentation: + metrics: + customQueries: + add: + - name: custom1 + databases: [pikachu, onix] + queries: + name: my-custom-queries2 + key: custom1.yaml + - name: custom2 + databases: [onix] + queries: + name: my-custom-queries2 + key: custom2.yaml +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-custom-queries2 +data: + custom1.yaml: | + - sql: > + SELECT current_database() as dbname + , n.nspname as schemaname + , c.relname + , pg_catalog.pg_total_relation_size(c.oid) as bytes + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid + WHERE NOT pg_is_other_temp_schema(n.oid) + AND relkind IN ('r', 'm', 'f'); + metrics: + - metric_name: ccp_table_size_bytes_1 + value_type: double + value_column: bytes + description: "Table size in bytes including indexes" + attribute_columns: ["dbname", "schemaname", "relname"] + static_attributes: + server: "localhost:5432" + custom2.yaml: | + - sql: > + SELECT current_database() as dbname + , n.nspname as schemaname + , c.relname + , pg_catalog.pg_total_relation_size(c.oid) as bytes + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON c.relnamespace = n.oid + WHERE NOT pg_is_other_temp_schema(n.oid) + AND relkind IN ('r', 'm', 'f'); + metrics: + - metric_name: ccp_table_size_bytes_2 + value_type: double + value_column: bytes + description: "Table size in bytes including indexes" + attribute_columns: ["dbname", "schemaname", "relname"] + static_attributes: + server: "localhost:5432" \ No newline at end of file diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/17-custom-queries-per-db-added.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/17-custom-queries-per-db-added.yaml new file mode 100644 index 0000000000..5bd9cec286 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/17-custom-queries-per-db-added.yaml @@ -0,0 +1,124 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 + proxy: + pgBouncer: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: database + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgbackrest + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/role: pgbouncer + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbouncer + ready: true + started: true + - name: pgbouncer-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-primary +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgadmin + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgadmin + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Secret +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +type: Opaque +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-custom-queries2 diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/19--add-logs-metrics-exporter.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/19--add-logs-metrics-exporter.yaml new file mode 100644 index 0000000000..67926505c0 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/19--add-logs-metrics-exporter.yaml @@ -0,0 +1,209 @@ +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +spec: + postgresVersion: ${KUTTL_PG_VERSION} + instances: + - name: instance1 + dataVolumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + backups: + pgbackrest: + manual: + repoName: repo1 + options: + - --type=diff + repos: + - name: repo1 + volume: + volumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + config: + parameters: + log_min_messages: INFO + proxy: + pgBouncer: {} + instrumentation: + metrics: + exporters: ['otlp'] + customQueries: + add: + - name: slow-custom-queries + queries: + name: my-custom-queries + key: my-slow-custom-queries.yaml + collectionInterval: 300s + - name: 2fast2furious + queries: + name: my-custom-queries + key: my-fast-custom-queries.yaml + remove: + - ccp_connection_stats_active + - ccp_database_size_bytes + config: + exporters: + otlp: + endpoint: otel-collector:4317 + tls: + insecure: true + logs: + exporters: ['otlp'] + retentionPeriod: 1h +--- +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PGAdmin +metadata: + name: otel-pgadmin +spec: + users: + - username: otel@example.com + role: Administrator + passwordRef: + name: pgadmin-password-secret + key: otel-password + dataVolumeClaimSpec: + accessModes: + - "ReadWriteOnce" + resources: + requests: + storage: 1Gi + serverGroups: + - name: supply + # An empty selector selects all postgresclusters in the Namespace + postgresClusterSelector: {} + config: + settings: + AUTHENTICATION_SOURCES: ['internal'] + instrumentation: + config: + exporters: + otlp: + endpoint: otel-collector:4317 + tls: + insecure: true + logs: + exporters: ['otlp'] + retentionPeriod: 1h +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: otel-collector-conf + labels: + app: opentelemetry + component: otel-collector-conf +data: + otel-collector-config: | + receivers: + otlp: + protocols: + grpc: + endpoint: ${env:MY_POD_IP}:4317 + http: + endpoint: ${env:MY_POD_IP}:4318 + extensions: + zpages: {} + exporters: + debug: + verbosity: detailed + service: + extensions: [zpages] + pipelines: + logs/1: + receivers: [otlp] + exporters: [debug] + metrics/1: + receivers: [otlp] + exporters: [debug] +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-collector + labels: + app: opentelemetry + component: otel-collector +spec: + ports: + - name: otlp-grpc # Default endpoint for OpenTelemetry gRPC receiver. + port: 4317 + protocol: TCP + targetPort: 4317 + - name: otlp-http # Default endpoint for OpenTelemetry HTTP receiver. + port: 4318 + protocol: TCP + targetPort: 4318 + - name: metrics # Default endpoint for querying metrics. + port: 8888 + selector: + component: otel-collector +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector + labels: + app: opentelemetry + component: otel-collector +spec: + selector: + matchLabels: + app: opentelemetry + component: otel-collector + minReadySeconds: 5 + progressDeadlineSeconds: 120 + replicas: 1 #TODO - adjust this to your own requirements + template: + metadata: + labels: + app: opentelemetry + component: otel-collector + spec: + containers: + - command: + - "/otelcol" + - "--config=/conf/otel-collector-config.yaml" + image: otel/opentelemetry-collector:latest + name: otel-collector + resources: + limits: + cpu: 1000m + memory: 2Gi + requests: + cpu: 200m + memory: 400Mi + ports: + - containerPort: 55679 # Default endpoint for ZPages. + - containerPort: 4317 # Default endpoint for OpenTelemetry receiver. + - containerPort: 14250 # Default endpoint for Jaeger gRPC receiver. + - containerPort: 14268 # Default endpoint for Jaeger HTTP receiver. + - containerPort: 9411 # Default endpoint for Zipkin receiver. + - containerPort: 8888 # Default endpoint for querying metrics. + env: + - name: MY_POD_IP + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: status.podIP + - name: GOMEMLIMIT + value: 1600MiB + volumeMounts: + - name: otel-collector-config-vol + mountPath: /conf + volumes: + - configMap: + name: otel-collector-conf + items: + - key: otel-collector-config + path: otel-collector-config.yaml + name: otel-collector-config-vol diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/19-logs-metrics-exporter-added.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/19-logs-metrics-exporter-added.yaml new file mode 100644 index 0000000000..f730898692 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/19-logs-metrics-exporter-added.yaml @@ -0,0 +1,155 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 + proxy: + pgBouncer: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: database + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgbackrest + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/role: pgbouncer + postgres-operator.crunchydata.com/cluster: otel-cluster + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbouncer + ready: true + started: true + - name: pgbouncer-config + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-primary +--- +apiVersion: v1 +kind: ConfigMap +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgadmin + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgadmin + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Secret +metadata: + labels: + postgres-operator.crunchydata.com/role: pgadmin + postgres-operator.crunchydata.com/pgadmin: otel-pgadmin +type: Opaque +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: my-custom-queries +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: otel-collector-conf +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-collector +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector +status: + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + app: opentelemetry +status: + containerStatuses: + - name: otel-collector + ready: true + started: true + phase: Running diff --git a/testing/kuttl/e2e-other/resize-volume/13--resize.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/21--create-cluster.yaml similarity index 53% rename from testing/kuttl/e2e-other/resize-volume/13--resize.yaml rename to testing/kuttl/e2e/otel-logging-and-metrics/files/21--create-cluster.yaml index 77af2f2aa3..3983405b34 100644 --- a/testing/kuttl/e2e-other/resize-volume/13--resize.yaml +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/21--create-cluster.yaml @@ -1,7 +1,8 @@ +--- apiVersion: postgres-operator.crunchydata.com/v1beta1 kind: PostgresCluster metadata: - name: resize-volume-down + name: otel-cluster-no-backups spec: postgresVersion: ${KUTTL_PG_VERSION} instances: @@ -12,14 +13,4 @@ spec: resources: requests: storage: 1Gi - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: - accessModes: - - "ReadWriteOnce" - resources: - requests: - storage: 1Gi + instrumentation: {} diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/21-cluster-created.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/21-cluster-created.yaml new file mode 100644 index 0000000000..c9aad7ec25 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/21-cluster-created.yaml @@ -0,0 +1,36 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster-no-backups +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster-no-backups + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: database + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-no-backups-primary diff --git a/testing/kuttl/e2e-other/resize-volume/01--cluster.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/23--add-backups.yaml similarity index 79% rename from testing/kuttl/e2e-other/resize-volume/01--cluster.yaml rename to testing/kuttl/e2e/otel-logging-and-metrics/files/23--add-backups.yaml index 4737fb25f4..bb7c70ea37 100644 --- a/testing/kuttl/e2e-other/resize-volume/01--cluster.yaml +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/23--add-backups.yaml @@ -1,7 +1,8 @@ +--- apiVersion: postgres-operator.crunchydata.com/v1beta1 kind: PostgresCluster metadata: - name: resize-volume-up + name: otel-cluster-no-backups spec: postgresVersion: ${KUTTL_PG_VERSION} instances: @@ -14,6 +15,10 @@ spec: storage: 1Gi backups: pgbackrest: + manual: + repoName: repo1 + options: + - --type=diff repos: - name: repo1 volume: @@ -23,3 +28,4 @@ spec: resources: requests: storage: 1Gi + instrumentation: {} diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/23-backups-added.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/23-backups-added.yaml new file mode 100644 index 0000000000..52221d2349 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/23-backups-added.yaml @@ -0,0 +1,71 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster-no-backups +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster-no-backups + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: database + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: pgbackrest + postgres-operator.crunchydata.com/cluster: otel-cluster-no-backups + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: pgbackrest + ready: true + started: true + - name: pgbackrest-config + ready: true + started: true + phase: Running +--- +apiVersion: batch/v1 +kind: Job +metadata: + labels: + postgres-operator.crunchydata.com/cluster: otel-cluster-no-backups + postgres-operator.crunchydata.com/pgbackrest-backup: replica-create +status: + succeeded: 1 +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-no-backups-primary diff --git a/testing/kuttl/e2e/otel-logging-and-metrics/files/25-backups-removed.yaml b/testing/kuttl/e2e/otel-logging-and-metrics/files/25-backups-removed.yaml new file mode 100644 index 0000000000..c9aad7ec25 --- /dev/null +++ b/testing/kuttl/e2e/otel-logging-and-metrics/files/25-backups-removed.yaml @@ -0,0 +1,36 @@ +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: otel-cluster-no-backups +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 +--- +apiVersion: v1 +kind: Pod +metadata: + labels: + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/role: master + postgres-operator.crunchydata.com/cluster: otel-cluster-no-backups + postgres-operator.crunchydata.com/crunchy-otel-collector: "true" +status: + containerStatuses: + - name: collector + ready: true + started: true + - name: database + ready: true + started: true + - name: replication-cert-copy + ready: true + started: true + phase: Running +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-cluster-no-backups-primary diff --git a/testing/kuttl/e2e/password-change/04--secret.yaml b/testing/kuttl/e2e/password-change/04--secret.yaml index f5cd1537c9..5f312e9bf1 100644 --- a/testing/kuttl/e2e/password-change/04--secret.yaml +++ b/testing/kuttl/e2e/password-change/04--secret.yaml @@ -1,3 +1,4 @@ +--- apiVersion: v1 kind: Secret metadata: @@ -7,3 +8,14 @@ stringData: password: infopond verifier: "md585eb8fa4f697b2ea949d3aba788e8631" uri: "" +--- +# Enable authenticating with MD5 passwords +apiVersion: postgres-operator.crunchydata.com/v1beta1 +kind: PostgresCluster +metadata: + name: password-change +spec: + authentication: + rules: + - connection: hostssl + method: md5 diff --git a/testing/kuttl/e2e/pgadmin/01--cluster.yaml b/testing/kuttl/e2e/pgadmin/01--cluster.yaml deleted file mode 100644 index d1afb7be04..0000000000 --- a/testing/kuttl/e2e/pgadmin/01--cluster.yaml +++ /dev/null @@ -1,40 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: test-cm -data: - configMap: config ---- -apiVersion: v1 -kind: Secret -metadata: - name: test-secret -type: Opaque -stringData: - password: myPassword ---- -# Create a cluster with a configured pgAdmin UI. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: interfaced - labels: { postgres-operator-test: kuttl } -spec: - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - name: instance1 - replicas: 1 - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - userInterface: - pgAdmin: - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - config: - files: - - secret: - name: test-secret - - configMap: - name: test-cm - settings: - SHOW_GRAVATAR_IMAGE: False - LOGIN_BANNER: | - Custom KUTTL Login Banner diff --git a/testing/kuttl/e2e/pgadmin/01-assert.yaml b/testing/kuttl/e2e/pgadmin/01-assert.yaml deleted file mode 100644 index e4192a1217..0000000000 --- a/testing/kuttl/e2e/pgadmin/01-assert.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: interfaced -status: - instances: - - name: instance1 - replicas: 1 - readyReplicas: 1 - updatedReplicas: 1 - ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: interfaced-pgadmin -status: - replicas: 1 - readyReplicas: 1 - updatedReplicas: 1 - ---- -apiVersion: v1 -kind: Secret -metadata: - name: test-secret ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: test-cm diff --git a/testing/kuttl/e2e/pgadmin/02--check-settings.yaml b/testing/kuttl/e2e/pgadmin/02--check-settings.yaml deleted file mode 100644 index c68d032d1e..0000000000 --- a/testing/kuttl/e2e/pgadmin/02--check-settings.yaml +++ /dev/null @@ -1,56 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - # Log the amount of space on the startup volume. Assert that 4KiB are used. - - script: | - kubectl exec --namespace "${NAMESPACE}" statefulset.apps/interfaced-pgadmin \ - -- df --block-size=1K /etc/pgadmin | - awk '{ print } END { exit ($3 != "4") }' - - # Assert that current settings contain values from the spec. - - script: | - SETTINGS=$( - kubectl exec --namespace "${NAMESPACE}" statefulset.apps/interfaced-pgadmin \ - -- cat /etc/pgadmin/conf.d/~postgres-operator/pgadmin.json - ) - - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - { - contains "${SETTINGS}" '"LOGIN_BANNER": "Custom KUTTL Login Banner\n"' && - contains "${SETTINGS}" '"SHOW_GRAVATAR_IMAGE": false' - } || { - echo >&2 'Wrong settings!' - echo "${SETTINGS}" - exit 1 - } - - - script: | - CONTENTS=$( - kubectl exec --namespace "${NAMESPACE}" statefulset.apps/interfaced-pgadmin \ - -- cat /etc/pgadmin/conf.d/configMap - ) - - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - { - contains "${CONTENTS}" 'config' - } || { - echo >&2 'Wrong settings!' - echo "${CONTENTS}" - exit 1 - } - - - script: | - CONTENTS=$( - kubectl exec --namespace "${NAMESPACE}" statefulset.apps/interfaced-pgadmin \ - -- cat /etc/pgadmin/conf.d/password - ) - - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - { - contains "${CONTENTS}" 'myPassword' - } || { - echo >&2 'Wrong settings!' - echo "${CONTENTS}" - exit 1 - } diff --git a/testing/kuttl/e2e/pgbackrest-restore/01--create-cluster.yaml b/testing/kuttl/e2e/pgbackrest-restore/01--create-cluster.yaml deleted file mode 100644 index c414806892..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/01--create-cluster.yaml +++ /dev/null @@ -1,26 +0,0 @@ ---- -# Create a cluster with a single pgBackRest repository and some parameters that -# require attention during PostgreSQL recovery. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: original - labels: { postgres-operator-test: kuttl } -spec: - postgresVersion: ${KUTTL_PG_VERSION} - patroni: - dynamicConfiguration: - postgresql: - parameters: - max_connections: 200 - instances: - - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - replicas: 2 - backups: - pgbackrest: - manual: - repoName: repo1 - repos: - - name: repo1 - volume: - volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } diff --git a/testing/kuttl/e2e/pgbackrest-restore/01-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/01-assert.yaml deleted file mode 100644 index 25b5bbee76..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/01-assert.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -# Wait for the replica backup to complete. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: original -status: - pgbackrest: - repos: - - name: repo1 - replicaCreateBackupComplete: true - stanzaCreated: true diff --git a/testing/kuttl/e2e/pgbackrest-restore/02--create-data.yaml b/testing/kuttl/e2e/pgbackrest-restore/02--create-data.yaml deleted file mode 100644 index 6801edbf61..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/02--create-data.yaml +++ /dev/null @@ -1,32 +0,0 @@ ---- -# Create some data that will be restored. -apiVersion: batch/v1 -kind: Job -metadata: - name: original-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - name: PGURI - valueFrom: { secretKeyRef: { name: original-pguser-original, key: uri } } - - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - command: - - psql - - $(PGURI) - - --set=ON_ERROR_STOP=1 - - --command - - | - CREATE SCHEMA "original"; - CREATE TABLE important (data) AS VALUES ('treasure'); diff --git a/testing/kuttl/e2e/pgbackrest-restore/02-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/02-assert.yaml deleted file mode 100644 index 5115ba97c9..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/02-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: original-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e/pgbackrest-restore/03--backup.yaml b/testing/kuttl/e2e/pgbackrest-restore/03--backup.yaml deleted file mode 100644 index b759dd0fc4..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/03--backup.yaml +++ /dev/null @@ -1,8 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - # Annotate the cluster to trigger a backup. - - script: | - kubectl annotate --namespace="${NAMESPACE}" postgrescluster/original \ - 'postgres-operator.crunchydata.com/pgbackrest-backup=one' diff --git a/testing/kuttl/e2e/pgbackrest-restore/03-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/03-assert.yaml deleted file mode 100644 index a2c5b3bb22..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/03-assert.yaml +++ /dev/null @@ -1,13 +0,0 @@ ---- -# Wait for the backup job to complete. -apiVersion: batch/v1 -kind: Job -metadata: - annotations: - postgres-operator.crunchydata.com/pgbackrest-backup: one - labels: - postgres-operator.crunchydata.com/cluster: original - postgres-operator.crunchydata.com/pgbackrest-backup: manual - postgres-operator.crunchydata.com/pgbackrest-repo: repo1 -status: - succeeded: 1 diff --git a/testing/kuttl/e2e/pgbackrest-restore/04--clone-cluster.yaml b/testing/kuttl/e2e/pgbackrest-restore/04--clone-cluster.yaml deleted file mode 100644 index 4bc1ce56a9..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/04--clone-cluster.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -# Clone the cluster using a pgBackRest restore. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: clone-one - labels: { postgres-operator-test: kuttl } -spec: - dataSource: - postgresCluster: - clusterName: original - repoName: repo1 - - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } diff --git a/testing/kuttl/e2e/pgbackrest-restore/04-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/04-assert.yaml deleted file mode 100644 index 8aa51fc440..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/04-assert.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -# Wait for the clone cluster to come online. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: clone-one -status: - instances: - - name: '00' - replicas: 1 - readyReplicas: 1 - updatedReplicas: 1 diff --git a/testing/kuttl/e2e/pgbackrest-restore/05--check-data.yaml b/testing/kuttl/e2e/pgbackrest-restore/05--check-data.yaml deleted file mode 100644 index 1ee6fe9c32..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/05--check-data.yaml +++ /dev/null @@ -1,49 +0,0 @@ ---- -# Confirm that all the data was restored. -apiVersion: batch/v1 -kind: Job -metadata: - name: clone-one-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - # Connect to the cluster using the restored database and original credentials. - - name: PGHOST - valueFrom: { secretKeyRef: { name: clone-one-pguser-clone-one, key: host } } - - name: PGPORT - valueFrom: { secretKeyRef: { name: clone-one-pguser-clone-one, key: port } } - - name: PGDATABASE - valueFrom: { secretKeyRef: { name: original-pguser-original, key: dbname } } - - name: PGUSER - valueFrom: { secretKeyRef: { name: original-pguser-original, key: user } } - - name: PGPASSWORD - valueFrom: { secretKeyRef: { name: original-pguser-original, key: password } } - - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - # Confirm that all the data was restored. - # Note: the `$$$$` is reduced to `$$` by Kubernetes. - # - https://kubernetes.io/docs/tasks/inject-data-application/ - command: - - psql - - -qa - - --set=ON_ERROR_STOP=1 - - --command - - | - DO $$$$ - DECLARE - restored jsonb; - BEGIN - SELECT jsonb_agg(important) INTO restored FROM important; - ASSERT restored = '[{"data":"treasure"}]', format('got %L', restored); - END $$$$; diff --git a/testing/kuttl/e2e/pgbackrest-restore/05-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/05-assert.yaml deleted file mode 100644 index 1b6fad318b..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/05-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: clone-one-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e/pgbackrest-restore/06--delete-clone.yaml b/testing/kuttl/e2e/pgbackrest-restore/06--delete-clone.yaml deleted file mode 100644 index 69ebc06c9d..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/06--delete-clone.yaml +++ /dev/null @@ -1,8 +0,0 @@ ---- -# Remove the cloned cluster. -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -delete: - - apiVersion: postgres-operator.crunchydata.com/v1beta1 - kind: PostgresCluster - name: clone-one diff --git a/testing/kuttl/e2e/pgbackrest-restore/07--annotate.yaml b/testing/kuttl/e2e/pgbackrest-restore/07--annotate.yaml deleted file mode 100644 index 279c216ed0..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/07--annotate.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -# Annotate the cluster with the timestamp at which PostgreSQL last started. -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - PRIMARY=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=original, - postgres-operator.crunchydata.com/role=master' - ) - START=$( - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ - -- psql -qAt --command 'SELECT pg_postmaster_start_time()' - ) - kubectl annotate --namespace "${NAMESPACE}" postgrescluster/original \ - "testing/start-before=${START}" diff --git a/testing/kuttl/e2e/pgbackrest-restore/07--update-cluster.yaml b/testing/kuttl/e2e/pgbackrest-restore/07--update-cluster.yaml deleted file mode 100644 index f83a02c7c6..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/07--update-cluster.yaml +++ /dev/null @@ -1,25 +0,0 @@ ---- -# Update the cluster with PostgreSQL parameters that require attention during recovery. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: original - labels: { postgres-operator-test: kuttl } -spec: - postgresVersion: ${KUTTL_PG_VERSION} - patroni: - dynamicConfiguration: - postgresql: - parameters: - max_connections: 1000 - instances: - - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - replicas: 2 - backups: - pgbackrest: - manual: - repoName: repo1 - repos: - - name: repo1 - volume: - volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } diff --git a/testing/kuttl/e2e/pgbackrest-restore/08--wait-restart.yaml b/testing/kuttl/e2e/pgbackrest-restore/08--wait-restart.yaml deleted file mode 100644 index 305d757386..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/08--wait-restart.yaml +++ /dev/null @@ -1,29 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - BEFORE=$( - kubectl get --namespace "${NAMESPACE}" postgrescluster/original \ - --output 'go-template={{ index .metadata.annotations "testing/start-before" }}' - ) - PRIMARY=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=original, - postgres-operator.crunchydata.com/role=master' - ) - - # Wait for PostgreSQL to restart. - while true; do - START=$( - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ - -- psql -qAt --command 'SELECT pg_postmaster_start_time()' - ) - if [ "${START}" ] && [ "${START}" != "${BEFORE}" ]; then break; else sleep 1; fi - done - echo "${START} != ${BEFORE}" - - # Reset counters in the "pg_stat_archiver" view. - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ - -- psql -qb --command "SELECT pg_stat_reset_shared('archiver')" diff --git a/testing/kuttl/e2e/pgbackrest-restore/09--add-data.yaml b/testing/kuttl/e2e/pgbackrest-restore/09--add-data.yaml deleted file mode 100644 index 41c2255239..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/09--add-data.yaml +++ /dev/null @@ -1,31 +0,0 @@ ---- -# Add more data to the WAL archive. -apiVersion: batch/v1 -kind: Job -metadata: - name: original-more-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - name: PGURI - valueFrom: { secretKeyRef: { name: original-pguser-original, key: uri } } - - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - command: - - psql - - $(PGURI) - - --set=ON_ERROR_STOP=1 - - --command - - | - INSERT INTO important (data) VALUES ('water'), ('socks'); diff --git a/testing/kuttl/e2e/pgbackrest-restore/09-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/09-assert.yaml deleted file mode 100644 index a60cd9ab8f..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/09-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: original-more-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e/pgbackrest-restore/10--wait-archived.yaml b/testing/kuttl/e2e/pgbackrest-restore/10--wait-archived.yaml deleted file mode 100644 index 446886ead3..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/10--wait-archived.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - PRIMARY=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=original, - postgres-operator.crunchydata.com/role=master' - ) - - # Wait for the data to be sent to the WAL archive. A prior step reset the - # "pg_stat_archiver" counters, so anything more than zero should suffice. - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -- psql -c 'SELECT pg_switch_wal()' - while [ 0 = "$( - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -- psql -qAt -c 'SELECT archived_count FROM pg_stat_archiver' - )" ]; do sleep 1; done diff --git a/testing/kuttl/e2e/pgbackrest-restore/11--clone-cluster.yaml b/testing/kuttl/e2e/pgbackrest-restore/11--clone-cluster.yaml deleted file mode 100644 index fcbdde4ea7..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/11--clone-cluster.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -# Clone the cluster using a pgBackRest restore. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: clone-two - labels: { postgres-operator-test: kuttl } -spec: - dataSource: - postgresCluster: - clusterName: original - repoName: repo1 - - postgresVersion: ${KUTTL_PG_VERSION} - instances: - - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } - backups: - pgbackrest: - repos: - - name: repo1 - volume: - volumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } diff --git a/testing/kuttl/e2e/pgbackrest-restore/11-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/11-assert.yaml deleted file mode 100644 index 0ad9669a62..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/11-assert.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -# Wait for the clone cluster to come online. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: clone-two -status: - instances: - - name: '00' - replicas: 1 - readyReplicas: 1 - updatedReplicas: 1 diff --git a/testing/kuttl/e2e/pgbackrest-restore/12--check-data.yaml b/testing/kuttl/e2e/pgbackrest-restore/12--check-data.yaml deleted file mode 100644 index 2cd2e4932b..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/12--check-data.yaml +++ /dev/null @@ -1,51 +0,0 @@ ---- -# Confirm that all the data was restored. -apiVersion: batch/v1 -kind: Job -metadata: - name: clone-two-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - # Connect to the cluster using the restored database and original credentials. - - name: PGHOST - valueFrom: { secretKeyRef: { name: clone-two-pguser-clone-two, key: host } } - - name: PGPORT - valueFrom: { secretKeyRef: { name: clone-two-pguser-clone-two, key: port } } - - name: PGDATABASE - valueFrom: { secretKeyRef: { name: original-pguser-original, key: dbname } } - - name: PGUSER - valueFrom: { secretKeyRef: { name: original-pguser-original, key: user } } - - name: PGPASSWORD - valueFrom: { secretKeyRef: { name: original-pguser-original, key: password } } - - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - # Confirm that all the data was restored. - # Note: the `$$$$` is reduced to `$$` by Kubernetes. - # - https://kubernetes.io/docs/tasks/inject-data-application/ - command: - - psql - - -qa - - --set=ON_ERROR_STOP=1 - - --command - - | - DO $$$$ - DECLARE - restored jsonb; - BEGIN - SELECT jsonb_agg(important) INTO restored FROM important; - ASSERT restored = '[ - {"data":"treasure"}, {"data":"water"}, {"data":"socks"} - ]', format('got %L', restored); - END $$$$; diff --git a/testing/kuttl/e2e/pgbackrest-restore/12-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/12-assert.yaml deleted file mode 100644 index 198d196836..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/12-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: clone-two-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e/pgbackrest-restore/13--delete-clone.yaml b/testing/kuttl/e2e/pgbackrest-restore/13--delete-clone.yaml deleted file mode 100644 index 9646f66f35..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/13--delete-clone.yaml +++ /dev/null @@ -1,8 +0,0 @@ ---- -# Remove the cloned cluster. -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -delete: - - apiVersion: postgres-operator.crunchydata.com/v1beta1 - kind: PostgresCluster - name: clone-two diff --git a/testing/kuttl/e2e/pgbackrest-restore/14--lose-data.yaml b/testing/kuttl/e2e/pgbackrest-restore/14--lose-data.yaml deleted file mode 100644 index 4f1eaeaa53..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/14--lose-data.yaml +++ /dev/null @@ -1,50 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - PRIMARY=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=original, - postgres-operator.crunchydata.com/role=master' - ) - OBJECTIVE=$( - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ - -- psql -qAt --command 'SELECT clock_timestamp()' - ) - - # Store the recovery objective for later steps. - kubectl annotate --namespace "${NAMESPACE}" postgrescluster/original \ - "testing/objective=${OBJECTIVE}" - - # A reason to restore. Wait for the change to be sent to the WAL archive. - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" \ - -- psql -qb original --set ON_ERROR_STOP=1 \ - --command 'DROP TABLE original.important' \ - --command "SELECT pg_stat_reset_shared('archiver')" \ - --command 'SELECT pg_switch_wal()' - - while [ 0 = "$( - kubectl exec --namespace "${NAMESPACE}" "${PRIMARY}" -- psql -qAt -c 'SELECT archived_count FROM pg_stat_archiver' - )" ]; do sleep 1; done - - # The replica should also need to be restored. - - script: | - REPLICA=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=original, - postgres-operator.crunchydata.com/role=replica' - ) - - kubectl exec --stdin --namespace "${NAMESPACE}" "${REPLICA}" \ - -- psql -qb original --set ON_ERROR_STOP=1 \ - --file=- <<'SQL' - DO $$ - BEGIN - ASSERT to_regclass('important') IS NULL, 'expected no table'; - PERFORM * FROM information_schema.tables WHERE table_name = 'important'; - ASSERT NOT FOUND, 'expected no table'; - END $$ - SQL diff --git a/testing/kuttl/e2e/pgbackrest-restore/15--in-place-pitr.yaml b/testing/kuttl/e2e/pgbackrest-restore/15--in-place-pitr.yaml deleted file mode 100644 index 3e647946db..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/15--in-place-pitr.yaml +++ /dev/null @@ -1,50 +0,0 @@ ---- -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - TARGET_JSON=$( - kubectl get --namespace "${NAMESPACE}" postgrescluster/original \ - --output 'go-template={{ index .metadata.annotations "testing/objective" | printf "--target=%q" | printf "%q" }}' - ) - - # Configure the cluster for an in-place point-in-time restore (PITR). - kubectl patch --namespace "${NAMESPACE}" postgrescluster/original \ - --type 'merge' --patch ' - {"spec":{"backups":{"pgbackrest":{"restore":{ - "enabled": true, - "repoName": "repo1", - "options": ["--type=time", '"${TARGET_JSON}"'] - }}}}}' - - # Annotate the cluster to trigger the restore. - kubectl annotate --namespace="${NAMESPACE}" postgrescluster/original \ - 'postgres-operator.crunchydata.com/pgbackrest-restore=one' - - # TODO(benjaminjb): remove this when PG10 is no longer being supported - # For PG10, we need to run a patronictl reinit for the replica when that is running - # Get the replica name--the replica will exist during the PITR process so we don't need to wait - if [[ ${KUTTL_PG_VERSION} == 10 ]]; then - # Find replica - REPLICA=$(kubectl get pods --namespace "${NAMESPACE}" \ - --selector=' - postgres-operator.crunchydata.com/cluster=original, - postgres-operator.crunchydata.com/data=postgres, - postgres-operator.crunchydata.com/role!=master' \ - --output=jsonpath={.items..metadata.name}) - - # Wait for replica to be deleted - kubectl wait pod/"${REPLICA}" --namespace "${NAMESPACE}" --for=delete --timeout=-1s - - # Wait for the restarted replica to be started - NOT_RUNNING="" - while [[ "${NOT_RUNNING}" == "" ]]; do - kubectl get pods --namespace "${NAMESPACE}" "${REPLICA}" || (sleep 1 && continue) - - NOT_RUNNING=$(kubectl get pods --namespace "${NAMESPACE}" "${REPLICA}" \ - --output jsonpath="{.status.containerStatuses[?(@.name=='database')].state.running.startedAt}") - sleep 1 - done - - kubectl exec --namespace "${NAMESPACE}" "${REPLICA}" -- patronictl reinit original-ha "${REPLICA}" --force - fi diff --git a/testing/kuttl/e2e/pgbackrest-restore/15-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/15-assert.yaml deleted file mode 100644 index c408b75a60..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/15-assert.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -# Wait for the restore to complete and the cluster to come online. -apiVersion: postgres-operator.crunchydata.com/v1beta1 -kind: PostgresCluster -metadata: - name: original -status: - instances: - - name: '00' - replicas: 2 - readyReplicas: 2 - updatedReplicas: 2 - pgbackrest: - restore: - id: one - finished: true diff --git a/testing/kuttl/e2e/pgbackrest-restore/16--check-data.yaml b/testing/kuttl/e2e/pgbackrest-restore/16--check-data.yaml deleted file mode 100644 index b0ae252831..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/16--check-data.yaml +++ /dev/null @@ -1,100 +0,0 @@ ---- -# Confirm that data was restored to the point-in-time. -apiVersion: batch/v1 -kind: Job -metadata: - name: original-pitr-primary-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - name: PGURI - valueFrom: { secretKeyRef: { name: original-pguser-original, key: uri } } - - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - # Note: the `$$$$` is reduced to `$$` by Kubernetes. - # - https://kubernetes.io/docs/tasks/inject-data-application/ - command: - - psql - - $(PGURI) - - -qa - - --set=ON_ERROR_STOP=1 - - --command - - | - DO $$$$ - DECLARE - restored jsonb; - BEGIN - SELECT jsonb_agg(important) INTO restored FROM important; - ASSERT restored = '[ - {"data":"treasure"}, {"data":"water"}, {"data":"socks"} - ]', format('got %L', restored); - END $$$$; - ---- -# Confirm that replicas are also restored and streaming from the primary. -apiVersion: batch/v1 -kind: Job -metadata: - name: original-pitr-replica-data - labels: { postgres-operator-test: kuttl } -spec: - backoffLimit: 3 - template: - metadata: - labels: { postgres-operator-test: kuttl } - spec: - restartPolicy: Never - containers: - - name: psql - image: ${KUTTL_PSQL_IMAGE} - env: - - name: PGPORT - valueFrom: { secretKeyRef: { name: original-pguser-original, key: port } } - - name: PGDATABASE - valueFrom: { secretKeyRef: { name: original-pguser-original, key: dbname } } - - name: PGUSER - valueFrom: { secretKeyRef: { name: original-pguser-original, key: user } } - - name: PGPASSWORD - valueFrom: { secretKeyRef: { name: original-pguser-original, key: password } } - - # The user secret does not contain the replica service. - - name: NAMESPACE - valueFrom: { fieldRef: { fieldPath: metadata.namespace } } - - name: PGHOST - value: "original-replicas.$(NAMESPACE).svc" - - # Do not wait indefinitely. - - { name: PGCONNECT_TIMEOUT, value: '5' } - - # Note: the `$$$$` is reduced to `$$` by Kubernetes. - # - https://kubernetes.io/docs/tasks/inject-data-application/ - command: - - psql - - -qa - - --set=ON_ERROR_STOP=1 - - --command - - | - DO $$$$ - DECLARE - restored jsonb; - BEGIN - ASSERT pg_is_in_recovery(), 'expected replica'; - -- only users with "pg_read_all_settings" role may examine "primary_conninfo" - -- ASSERT current_setting('primary_conninfo') <> '', 'expected streaming'; - - SELECT jsonb_agg(important) INTO restored FROM important; - ASSERT restored = '[ - {"data":"treasure"}, {"data":"water"}, {"data":"socks"} - ]', format('got %L', restored); - END $$$$; diff --git a/testing/kuttl/e2e/pgbackrest-restore/16-assert.yaml b/testing/kuttl/e2e/pgbackrest-restore/16-assert.yaml deleted file mode 100644 index 0baadef25b..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/16-assert.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: original-pitr-primary-data -status: - succeeded: 1 - ---- -apiVersion: batch/v1 -kind: Job -metadata: - name: original-pitr-replica-data -status: - succeeded: 1 diff --git a/testing/kuttl/e2e/pgbackrest-restore/17--check-replication.yaml b/testing/kuttl/e2e/pgbackrest-restore/17--check-replication.yaml deleted file mode 100644 index f6c813c8b1..0000000000 --- a/testing/kuttl/e2e/pgbackrest-restore/17--check-replication.yaml +++ /dev/null @@ -1,22 +0,0 @@ ---- -# Confirm that the replica is streaming from the primary. -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -commands: - - script: | - REPLICA=$( - kubectl get pod --namespace "${NAMESPACE}" \ - --output name --selector ' - postgres-operator.crunchydata.com/cluster=original, - postgres-operator.crunchydata.com/role=replica' - ) - - kubectl exec --stdin --namespace "${NAMESPACE}" "${REPLICA}" \ - -- psql -qb original --set ON_ERROR_STOP=1 \ - --file=- <<'SQL' - DO $$ - BEGIN - PERFORM * FROM pg_stat_wal_receiver WHERE status = 'streaming'; - ASSERT FOUND, 'expected streaming replication'; - END $$ - SQL diff --git a/testing/kuttl/e2e/security-context/00--cluster.yaml b/testing/kuttl/e2e/security-context/00--cluster.yaml index 5155eb4fc6..d754eedec6 100644 --- a/testing/kuttl/e2e/security-context/00--cluster.yaml +++ b/testing/kuttl/e2e/security-context/00--cluster.yaml @@ -18,9 +18,6 @@ spec: proxy: pgBouncer: replicas: 1 - userInterface: - pgAdmin: - dataVolumeClaimSpec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 1Gi } } } monitoring: pgmonitor: exporter: {} diff --git a/testing/kuttl/e2e/security-context/00-assert.yaml b/testing/kuttl/e2e/security-context/00-assert.yaml index a6a5f48b6a..6df19c6608 100644 --- a/testing/kuttl/e2e/security-context/00-assert.yaml +++ b/testing/kuttl/e2e/security-context/00-assert.yaml @@ -92,38 +92,6 @@ spec: readOnlyRootFilesystem: true runAsNonRoot: true --- -# pgAdmin -apiVersion: v1 -kind: Pod -metadata: - labels: - postgres-operator.crunchydata.com/cluster: security-context - postgres-operator.crunchydata.com/data: pgadmin - postgres-operator.crunchydata.com/role: pgadmin - statefulset.kubernetes.io/pod-name: security-context-pgadmin-0 - name: security-context-pgadmin-0 -spec: - containers: - - name: pgadmin - securityContext: - allowPrivilegeEscalation: false - privileged: false - readOnlyRootFilesystem: true - runAsNonRoot: true - initContainers: - - name: pgadmin-startup - securityContext: - allowPrivilegeEscalation: false - privileged: false - readOnlyRootFilesystem: true - runAsNonRoot: true - - name: nss-wrapper-init - securityContext: - allowPrivilegeEscalation: false - privileged: false - readOnlyRootFilesystem: true - runAsNonRoot: true ---- # pgBouncer apiVersion: v1 kind: Pod diff --git a/testing/kuttl/e2e/standalone-pgadmin/00--create-pgadmin.yaml b/testing/kuttl/e2e/standalone-pgadmin/00--create-pgadmin.yaml deleted file mode 100644 index ee1a03ec64..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/00--create-pgadmin.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/00-pgadmin.yaml -assert: -- files/00-pgadmin-check.yaml diff --git a/testing/kuttl/e2e/standalone-pgadmin/00-assert.yaml b/testing/kuttl/e2e/standalone-pgadmin/00-assert.yaml deleted file mode 100644 index 5b95b46964..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/00-assert.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -collectors: -- type: command - command: kubectl -n $NAMESPACE describe pods --selector postgres-operator.crunchydata.com/pgadmin=pgadmin -- namespace: $NAMESPACE - selector: postgres-operator.crunchydata.com/pgadmin=pgadmin diff --git a/testing/kuttl/e2e/standalone-pgadmin/01-assert.yaml b/testing/kuttl/e2e/standalone-pgadmin/01-assert.yaml deleted file mode 100644 index 6b7c8c8794..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/01-assert.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -commands: -- script: | - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - - pod_name=$(kubectl get pod -n "${NAMESPACE}" -l postgres-operator.crunchydata.com/pgadmin=pgadmin -o name) - - clusters_actual=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c "python3 /usr/local/lib/python3.11/site-packages/pgadmin4/setup.py dump-servers /tmp/dumped.json --user admin@pgadmin.${NAMESPACE}.svc && cat /tmp/dumped.json") - - clusters_expected="\"Servers\": {}" - { - contains "${clusters_actual}" "${clusters_expected}" - } || { - echo "Wrong servers dumped: got ${clusters_actual}" - exit 1 - } diff --git a/testing/kuttl/e2e/standalone-pgadmin/02--create-cluster.yaml b/testing/kuttl/e2e/standalone-pgadmin/02--create-cluster.yaml deleted file mode 100644 index bee91ce0a4..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/02--create-cluster.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/02-cluster.yaml -- files/02-pgadmin.yaml -assert: -- files/02-cluster-check.yaml diff --git a/testing/kuttl/e2e/standalone-pgadmin/03-assert.yaml b/testing/kuttl/e2e/standalone-pgadmin/03-assert.yaml deleted file mode 100644 index 169a8261eb..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/03-assert.yaml +++ /dev/null @@ -1,76 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -# Check the configmap is updated; -# Check the file is updated on the pod; -# Check the server dump is accurate. -# Because we have to wait for the configmap reload, make sure we have enough time. -timeout: 120 -commands: -- script: | - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - diff_comp() { bash -ceu 'diff <(echo "$1" ) <(echo "$2")' - "$@"; } - - data_expected='"pgadmin-shared-clusters.json": "{\n \"Servers\": {\n \"1\": {\n \"Group\": \"groupOne\",\n \"Host\": \"pgadmin1-primary.'${NAMESPACE}.svc'\",\n \"MaintenanceDB\": \"postgres\",\n \"Name\": \"pgadmin1\",\n \"Port\": 5432,\n \"SSLMode\": \"prefer\",\n \"Shared\": true,\n \"Username\": \"pgadmin1\"\n }\n }\n}\n"' - - data_actual=$(kubectl get cm -l postgres-operator.crunchydata.com/pgadmin=pgadmin -n "${NAMESPACE}" -o json | jq .items[0].data) - - { - contains "${data_actual}" "${data_expected}" - } || { - echo "Wrong configmap: got ${data_actual}" - exit 1 - } - - pod_name=$(kubectl get pod -n "${NAMESPACE}" -l postgres-operator.crunchydata.com/pgadmin=pgadmin -o name) - - config_updated=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c 'cat /etc/pgadmin/conf.d/~postgres-operator/pgadmin-shared-clusters.json') - config_expected='"Servers": { - "1": { - "Group": "groupOne", - "Host": "pgadmin1-primary.'${NAMESPACE}.svc'", - "MaintenanceDB": "postgres", - "Name": "pgadmin1", - "Port": 5432, - "SSLMode": "prefer", - "Shared": true, - "Username": "pgadmin1" - } - }' - { - contains "${config_updated}" "${config_expected}" - } || { - echo "Wrong file mounted: got ${config_updated}" - echo "Wrong file mounted: expected ${config_expected}" - sleep 10 - exit 1 - } - - clusters_actual=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c "python3 /usr/local/lib/python3.11/site-packages/pgadmin4/setup.py dump-servers /tmp/dumped.json --user admin@pgadmin.${NAMESPACE}.svc && cat /tmp/dumped.json") - - clusters_expected=' - { - "Servers": { - "1": { - "Name": "pgadmin1", - "Group": "groupOne", - "Host": "pgadmin1-primary.'${NAMESPACE}.svc'", - "Port": 5432, - "MaintenanceDB": "postgres", - "Username": "pgadmin1", - "Shared": true, - "TunnelPort": "22", - "KerberosAuthentication": false, - "ConnectionParameters": { - "sslmode": "prefer" - } - } - } - }' - { - contains "${clusters_actual}" "${clusters_expected}" - } || { - echo "Wrong servers dumped: got ${clusters_actual}" - echo "Wrong servers dumped: expected ${clusters_expected}" - diff_comp "${clusters_actual}" "${clusters_expected}" - exit 1 - } diff --git a/testing/kuttl/e2e/standalone-pgadmin/04--create-cluster.yaml b/testing/kuttl/e2e/standalone-pgadmin/04--create-cluster.yaml deleted file mode 100644 index 5701678501..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/04--create-cluster.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/04-cluster.yaml -assert: -- files/04-cluster-check.yaml diff --git a/testing/kuttl/e2e/standalone-pgadmin/05-assert.yaml b/testing/kuttl/e2e/standalone-pgadmin/05-assert.yaml deleted file mode 100644 index 7fe5b69dc2..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/05-assert.yaml +++ /dev/null @@ -1,102 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -# Check the configmap is updated; -# Check the file is updated on the pod; -# Check the server dump is accurate. -# Because we have to wait for the configmap reload, make sure we have enough time. -timeout: 120 -commands: -- script: | - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - diff_comp() { bash -ceu 'diff <(echo "$1" ) <(echo "$2")' - "$@"; } - - data_expected='"pgadmin-shared-clusters.json": "{\n \"Servers\": {\n \"1\": {\n \"Group\": \"groupOne\",\n \"Host\": \"pgadmin1-primary.'${NAMESPACE}.svc'\",\n \"MaintenanceDB\": \"postgres\",\n \"Name\": \"pgadmin1\",\n \"Port\": 5432,\n \"SSLMode\": \"prefer\",\n \"Shared\": true,\n \"Username\": \"pgadmin1\"\n },\n \"2\": {\n \"Group\": \"groupOne\",\n \"Host\": \"pgadmin2-primary.'${NAMESPACE}.svc'\",\n \"MaintenanceDB\": \"postgres\",\n \"Name\": \"pgadmin2\",\n \"Port\": 5432,\n \"SSLMode\": \"prefer\",\n \"Shared\": true,\n \"Username\": \"pgadmin2\"\n }\n }\n}\n"' - - data_actual=$(kubectl get cm -l postgres-operator.crunchydata.com/pgadmin=pgadmin -n "${NAMESPACE}" -o json | jq .items[0].data) - - { - contains "${data_actual}" "${data_expected}" - } || { - echo "Wrong configmap: got ${data_actual}" - diff_comp "${data_actual}" "${data_expected}" - exit 1 - } - - pod_name=$(kubectl get pod -n "${NAMESPACE}" -l postgres-operator.crunchydata.com/pgadmin=pgadmin -o name) - - config_updated=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c 'cat /etc/pgadmin/conf.d/~postgres-operator/pgadmin-shared-clusters.json') - config_expected='"Servers": { - "1": { - "Group": "groupOne", - "Host": "pgadmin1-primary.'${NAMESPACE}.svc'", - "MaintenanceDB": "postgres", - "Name": "pgadmin1", - "Port": 5432, - "SSLMode": "prefer", - "Shared": true, - "Username": "pgadmin1" - }, - "2": { - "Group": "groupOne", - "Host": "pgadmin2-primary.'${NAMESPACE}.svc'", - "MaintenanceDB": "postgres", - "Name": "pgadmin2", - "Port": 5432, - "SSLMode": "prefer", - "Shared": true, - "Username": "pgadmin2" - } - }' - { - contains "${config_updated}" "${config_expected}" - } || { - echo "Wrong file mounted: got ${config_updated}" - echo "Wrong file mounted: expected ${config_expected}" - diff_comp "${config_updated}" "${config_expected}" - sleep 10 - exit 1 - } - - clusters_actual=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c "python3 /usr/local/lib/python3.11/site-packages/pgadmin4/setup.py dump-servers /tmp/dumped.json --user admin@pgadmin.${NAMESPACE}.svc && cat /tmp/dumped.json") - - clusters_expected=' - { - "Servers": { - "1": { - "Name": "pgadmin1", - "Group": "groupOne", - "Host": "pgadmin1-primary.'${NAMESPACE}.svc'", - "Port": 5432, - "MaintenanceDB": "postgres", - "Username": "pgadmin1", - "Shared": true, - "TunnelPort": "22", - "KerberosAuthentication": false, - "ConnectionParameters": { - "sslmode": "prefer" - } - }, - "2": { - "Name": "pgadmin2", - "Group": "groupOne", - "Host": "pgadmin2-primary.'${NAMESPACE}.svc'", - "Port": 5432, - "MaintenanceDB": "postgres", - "Username": "pgadmin2", - "Shared": true, - "TunnelPort": "22", - "KerberosAuthentication": false, - "ConnectionParameters": { - "sslmode": "prefer" - } - } - } - }' - { - contains "${clusters_actual}" "${clusters_expected}" - } || { - echo "Wrong servers dumped: got ${clusters_actual}" - echo "Wrong servers dumped: expected ${clusters_expected}" - diff_comp "${clusters_actual}" "${clusters_expected}" - exit 1 - } diff --git a/testing/kuttl/e2e/standalone-pgadmin/06--create-cluster.yaml b/testing/kuttl/e2e/standalone-pgadmin/06--create-cluster.yaml deleted file mode 100644 index 86b5f8bf04..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/06--create-cluster.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -apply: -- files/06-cluster.yaml -- files/06-pgadmin.yaml -assert: -- files/06-cluster-check.yaml diff --git a/testing/kuttl/e2e/standalone-pgadmin/07-assert.yaml b/testing/kuttl/e2e/standalone-pgadmin/07-assert.yaml deleted file mode 100644 index 323237cad4..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/07-assert.yaml +++ /dev/null @@ -1,126 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -# Check the configmap is updated; -# Check the file is updated on the pod; -# Check the server dump is accurate. -# Because we have to wait for the configmap reload, make sure we have enough time. -timeout: 120 -commands: -- script: | - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - diff_comp() { bash -ceu 'diff <(echo "$1" ) <(echo "$2")' - "$@"; } - - data_expected='"pgadmin-shared-clusters.json": "{\n \"Servers\": {\n \"1\": {\n \"Group\": \"groupOne\",\n \"Host\": \"pgadmin1-primary.'${NAMESPACE}.svc'\",\n \"MaintenanceDB\": \"postgres\",\n \"Name\": \"pgadmin1\",\n \"Port\": 5432,\n \"SSLMode\": \"prefer\",\n \"Shared\": true,\n \"Username\": \"pgadmin1\"\n },\n \"2\": {\n \"Group\": \"groupOne\",\n \"Host\": \"pgadmin2-primary.'${NAMESPACE}.svc'\",\n \"MaintenanceDB\": \"postgres\",\n \"Name\": \"pgadmin2\",\n \"Port\": 5432,\n \"SSLMode\": \"prefer\",\n \"Shared\": true,\n \"Username\": \"pgadmin2\"\n },\n \"3\": {\n \"Group\": \"groupTwo\",\n \"Host\": \"pgadmin3-primary.'${NAMESPACE}.svc'\",\n \"MaintenanceDB\": \"postgres\",\n \"Name\": \"pgadmin3\",\n \"Port\": 5432,\n \"SSLMode\": \"prefer\",\n \"Shared\": true,\n \"Username\": \"pgadmin3\"\n }\n }\n}\n"' - - data_actual=$(kubectl get cm -l postgres-operator.crunchydata.com/pgadmin=pgadmin -n "${NAMESPACE}" -o json | jq .items[0].data) - - { - contains "${data_actual}" "${data_expected}" - } || { - echo "Wrong configmap: got ${data_actual}" - diff_comp "${data_actual}" "${data_expected}" - exit 1 - } - - pod_name=$(kubectl get pod -n "${NAMESPACE}" -l postgres-operator.crunchydata.com/pgadmin=pgadmin -o name) - - config_updated=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c 'cat /etc/pgadmin/conf.d/~postgres-operator/pgadmin-shared-clusters.json') - config_expected='"Servers": { - "1": { - "Group": "groupOne", - "Host": "pgadmin1-primary.'${NAMESPACE}.svc'", - "MaintenanceDB": "postgres", - "Name": "pgadmin1", - "Port": 5432, - "SSLMode": "prefer", - "Shared": true, - "Username": "pgadmin1" - }, - "2": { - "Group": "groupOne", - "Host": "pgadmin2-primary.'${NAMESPACE}.svc'", - "MaintenanceDB": "postgres", - "Name": "pgadmin2", - "Port": 5432, - "SSLMode": "prefer", - "Shared": true, - "Username": "pgadmin2" - }, - "3": { - "Group": "groupTwo", - "Host": "pgadmin3-primary.'${NAMESPACE}.svc'", - "MaintenanceDB": "postgres", - "Name": "pgadmin3", - "Port": 5432, - "SSLMode": "prefer", - "Shared": true, - "Username": "pgadmin3" - } - }' - { - contains "${config_updated}" "${config_expected}" - } || { - echo "Wrong file mounted: got ${config_updated}" - echo "Wrong file mounted: expected ${config_expected}" - diff_comp "${config_updated}" "${config_expected}" - sleep 10 - exit 1 - } - - clusters_actual=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c "python3 /usr/local/lib/python3.11/site-packages/pgadmin4/setup.py dump-servers /tmp/dumped.json --user admin@pgadmin.${NAMESPACE}.svc && cat /tmp/dumped.json") - - clusters_expected=' - { - "Servers": { - "1": { - "Name": "pgadmin1", - "Group": "groupOne", - "Host": "pgadmin1-primary.'${NAMESPACE}.svc'", - "Port": 5432, - "MaintenanceDB": "postgres", - "Username": "pgadmin1", - "Shared": true, - "TunnelPort": "22", - "KerberosAuthentication": false, - "ConnectionParameters": { - "sslmode": "prefer" - } - }, - "2": { - "Name": "pgadmin2", - "Group": "groupOne", - "Host": "pgadmin2-primary.'${NAMESPACE}.svc'", - "Port": 5432, - "MaintenanceDB": "postgres", - "Username": "pgadmin2", - "Shared": true, - "TunnelPort": "22", - "KerberosAuthentication": false, - "ConnectionParameters": { - "sslmode": "prefer" - } - }, - "3": { - "Name": "pgadmin3", - "Group": "groupTwo", - "Host": "pgadmin3-primary.'${NAMESPACE}.svc'", - "Port": 5432, - "MaintenanceDB": "postgres", - "Username": "pgadmin3", - "Shared": true, - "TunnelPort": "22", - "KerberosAuthentication": false, - "ConnectionParameters": { - "sslmode": "prefer" - } - } - } - }' - { - contains "${clusters_actual}" "${clusters_expected}" - } || { - echo "Wrong servers dumped: got ${clusters_actual}" - echo "Wrong servers dumped: expected ${clusters_expected}" - diff_comp "${clusters_actual}" "${clusters_expected}" - exit 1 - } diff --git a/testing/kuttl/e2e/standalone-pgadmin/08--delete-cluster.yaml b/testing/kuttl/e2e/standalone-pgadmin/08--delete-cluster.yaml deleted file mode 100644 index bc11ea62f4..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/08--delete-cluster.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -delete: - - apiVersion: postgres-operator.crunchydata.com/v1beta1 - kind: PostgresCluster - name: pgadmin2 -error: -- files/04-cluster-check.yaml diff --git a/testing/kuttl/e2e/standalone-pgadmin/09-assert.yaml b/testing/kuttl/e2e/standalone-pgadmin/09-assert.yaml deleted file mode 100644 index eca5581cb7..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/09-assert.yaml +++ /dev/null @@ -1,102 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestAssert -# Check the configmap is updated; -# Check the file is updated on the pod; -# Check the server dump is accurate. -# Because we have to wait for the configmap reload, make sure we have enough time. -timeout: 120 -commands: -- script: | - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - diff_comp() { bash -ceu 'diff <(echo "$1" ) <(echo "$2")' - "$@"; } - - data_expected='"pgadmin-shared-clusters.json": "{\n \"Servers\": {\n \"1\": {\n \"Group\": \"groupOne\",\n \"Host\": \"pgadmin1-primary.'${NAMESPACE}.svc'\",\n \"MaintenanceDB\": \"postgres\",\n \"Name\": \"pgadmin1\",\n \"Port\": 5432,\n \"SSLMode\": \"prefer\",\n \"Shared\": true,\n \"Username\": \"pgadmin1\"\n },\n \"2\": {\n \"Group\": \"groupTwo\",\n \"Host\": \"pgadmin3-primary.'${NAMESPACE}.svc'\",\n \"MaintenanceDB\": \"postgres\",\n \"Name\": \"pgadmin3\",\n \"Port\": 5432,\n \"SSLMode\": \"prefer\",\n \"Shared\": true,\n \"Username\": \"pgadmin3\"\n }\n }\n}\n"' - - data_actual=$(kubectl get cm -l postgres-operator.crunchydata.com/pgadmin=pgadmin -n "${NAMESPACE}" -o json | jq .items[0].data) - - { - contains "${data_actual}" "${data_expected}" - } || { - echo "Wrong configmap: got ${data_actual}" - diff_comp "${data_actual}" "${data_expected}" - exit 1 - } - - pod_name=$(kubectl get pod -n "${NAMESPACE}" -l postgres-operator.crunchydata.com/pgadmin=pgadmin -o name) - - config_updated=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c 'cat /etc/pgadmin/conf.d/~postgres-operator/pgadmin-shared-clusters.json') - config_expected='"Servers": { - "1": { - "Group": "groupOne", - "Host": "pgadmin1-primary.'${NAMESPACE}.svc'", - "MaintenanceDB": "postgres", - "Name": "pgadmin1", - "Port": 5432, - "SSLMode": "prefer", - "Shared": true, - "Username": "pgadmin1" - }, - "2": { - "Group": "groupTwo", - "Host": "pgadmin3-primary.'${NAMESPACE}.svc'", - "MaintenanceDB": "postgres", - "Name": "pgadmin3", - "Port": 5432, - "SSLMode": "prefer", - "Shared": true, - "Username": "pgadmin3" - } - }' - { - contains "${config_updated}" "${config_expected}" - } || { - echo "Wrong file mounted: got ${config_updated}" - echo "Wrong file mounted: expected ${config_expected}" - diff_comp "${config_updated}" "${config_expected}" - sleep 10 - exit 1 - } - - clusters_actual=$(kubectl exec -n "${NAMESPACE}" "${pod_name}" -- bash -c "python3 /usr/local/lib/python3.11/site-packages/pgadmin4/setup.py dump-servers /tmp/dumped.json --user admin@pgadmin.${NAMESPACE}.svc && cat /tmp/dumped.json") - - clusters_expected=' - { - "Servers": { - "1": { - "Name": "pgadmin1", - "Group": "groupOne", - "Host": "pgadmin1-primary.'${NAMESPACE}.svc'", - "Port": 5432, - "MaintenanceDB": "postgres", - "Username": "pgadmin1", - "Shared": true, - "TunnelPort": "22", - "KerberosAuthentication": false, - "ConnectionParameters": { - "sslmode": "prefer" - } - }, - "2": { - "Name": "pgadmin3", - "Group": "groupTwo", - "Host": "pgadmin3-primary.'${NAMESPACE}.svc'", - "Port": 5432, - "MaintenanceDB": "postgres", - "Username": "pgadmin3", - "Shared": true, - "TunnelPort": "22", - "KerberosAuthentication": false, - "ConnectionParameters": { - "sslmode": "prefer" - } - } - } - }' - { - contains "${clusters_actual}" "${clusters_expected}" - } || { - echo "Wrong servers dumped: got ${clusters_actual}" - echo "Wrong servers dumped: expected ${clusters_expected}" - diff_comp "${clusters_actual}" "${clusters_expected}" - exit 1 - } diff --git a/testing/kuttl/e2e/standalone-pgadmin/10-invalid-pgadmin.yaml b/testing/kuttl/e2e/standalone-pgadmin/10-invalid-pgadmin.yaml deleted file mode 100644 index 118b8d06ef..0000000000 --- a/testing/kuttl/e2e/standalone-pgadmin/10-invalid-pgadmin.yaml +++ /dev/null @@ -1,37 +0,0 @@ -apiVersion: kuttl.dev/v1beta1 -kind: TestStep -# Check that invalid spec cannot be applied. -commands: -- script: | - contains() { bash -ceu '[[ "$1" == *"$2"* ]]' - "$@"; } - diff_comp() { bash -ceu 'diff <(echo "$1" ) <(echo "$2")' - "$@"; } - - data_expected='"pgadmin2" is invalid: spec.serverGroups[0]: Invalid value: "object": exactly one of "postgresClusterName" or "postgresClusterSelector" is required' - - data_actual=$(kubectl apply -f - 2>&1 <