diff --git a/.github/dockerfiles/Dockerfile_extension b/.github/dockerfiles/Dockerfile_extension new file mode 100644 index 00000000..c0317e50 --- /dev/null +++ b/.github/dockerfiles/Dockerfile_extension @@ -0,0 +1,100 @@ +# DocumentDB extension image for CNPG ImageVolume mode. +# Contains only extension artifacts (.so, .control, .sql, bitcode) and +# runtime shared-library dependencies. PostgreSQL is provided by the +# CNPG base image at runtime. +# +# Follows the pattern from: +# https://github.com/cloudnative-pg/postgres-extensions-containers +# +# Usage: +# docker build \ +# --build-arg PG_MAJOR=18 \ +# --build-arg DEB_PACKAGE_REL_PATH=packages/documentdb_0.110-0_arm64.deb \ +# -t documentdb-extension:latest \ +# -f Dockerfile_extension . + +ARG BASE=ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie +FROM ${BASE} AS builder + +ARG PG_MAJOR=18 +ARG DEB_PACKAGE_REL_PATH + +USER 0 + +RUN set -eux && \ + # Snapshot base image system libraries for later diffing + ldconfig -p | awk '{print $NF}' | grep '^/' | sort | uniq > /tmp/base-image-libs.out && \ + # Install pgdg extension packages + apt-get update && \ + apt-get install -y --no-install-recommends \ + postgresql-${PG_MAJOR}-cron \ + postgresql-${PG_MAJOR}-pgvector \ + postgresql-${PG_MAJOR}-postgis-3 + +# Install the DocumentDB extension from a pre-built .deb +COPY ${DEB_PACKAGE_REL_PATH} /tmp/documentdb.deb +RUN dpkg -i /tmp/documentdb.deb && \ + rm -f /tmp/documentdb.deb + +# Gather system library dependencies not present in the CNPG base image +RUN set -eux && \ + mkdir -p /system /licenses && \ + find /usr/lib/postgresql/${PG_MAJOR}/lib/ -name '*.so' -print0 | \ + xargs -0 ldd 2>/dev/null | \ + awk '{print $3}' | grep '^/' | sort | uniq > /tmp/all-deps.out && \ + comm -13 /tmp/base-image-libs.out /tmp/all-deps.out > /tmp/libraries.out && \ + while read -r lib; do \ + resolved=$(readlink -f "$lib"); \ + dir=$(dirname "$lib"); \ + base=$(basename "$lib"); \ + cp -a "$resolved" /system/; \ + for file in "$dir"/"${base%.so*}.so"*; do \ + [ -e "$file" ] || continue; \ + if [ -L "$file" ] && [ "$(readlink -f "$file")" = "$resolved" ]; then \ + ln -sf "$(basename "$resolved")" "/system/$(basename "$file")"; \ + fi; \ + done; \ + done < /tmp/libraries.out && \ + for lib in $(find /system -maxdepth 1 -type f -name '*.so*'); do \ + pkg=$(dpkg -S "$(basename "$lib")" 2>/dev/null | grep -v "diversion by" | awk -F: '/:/{print $1; exit}'); \ + [ -z "$pkg" ] && continue; \ + mkdir -p "/licenses/$pkg" && \ + cp -a "/usr/share/doc/$pkg/copyright" "/licenses/$pkg/copyright" 2>/dev/null || true; \ + done + +# Resolve Debian-alternatives symlinks — they break in ImageVolume mode +# because /etc/alternatives/ is outside the volume mount. +RUN set -eux && \ + for link in \ + /usr/share/postgresql/${PG_MAJOR}/extension/*.control \ + /usr/share/postgresql/${PG_MAJOR}/extension/*.sql \ + /usr/lib/postgresql/${PG_MAJOR}/lib/*.so; do \ + [ -L "$link" ] || continue; \ + target=$(readlink -f "$link" 2>/dev/null); \ + if [ -n "$target" ] && [ -f "$target" ]; then \ + cp --remove-destination "$target" "$link"; \ + elif [ -L "$link" ] && [ ! -e "$link" ]; then \ + rm -f "$link"; \ + fi; \ + done + +# ---------- minimal final image with only extension artifacts ---------- +FROM scratch +ARG PG_MAJOR=18 + +# Licenses +COPY --from=builder /licenses/ /licenses/ + +# Extension shared libraries (.so and bitcode) +COPY --from=builder /usr/lib/postgresql/${PG_MAJOR}/lib/ /lib/ + +# Extension SQL and control files +COPY --from=builder /usr/share/postgresql/${PG_MAJOR}/extension/ /share/extension/ + +# PostGIS supporting data (spatial_ref_sys, topology, etc.) +COPY --from=builder /usr/share/postgresql/${PG_MAJOR}/contrib/ /share/contrib/ + +# System library dependencies not present in the CNPG base image +COPY --from=builder /system/ /system/ + +USER 65532:65532 diff --git a/.github/dockerfiles/Dockerfile_gateway_deb b/.github/dockerfiles/Dockerfile_gateway_deb new file mode 100644 index 00000000..657be78f --- /dev/null +++ b/.github/dockerfiles/Dockerfile_gateway_deb @@ -0,0 +1,60 @@ +# Lean gateway image — contains only the DocumentDB gateway binary +# Designed for use with the DocumentDB Kubernetes Operator (CNPG-based) +# +# Usage: +# docker build \ +# --build-arg GATEWAY_DEB_REL_PATH=packages/documentdb_gateway_0.1.0-1_arm64.deb \ +# -t documentdb-gateway:latest \ +# -f Dockerfile_gateway_deb +# +# The build context must contain: +# - The gateway .deb at GATEWAY_DEB_REL_PATH +# - scripts/gateway_entrypoint.sh +# - scripts/utils.sh +# - pg_documentdb_gw/SetupConfiguration.json +ARG BASE_IMAGE=debian:trixie-slim +FROM ${BASE_IMAGE} + +ARG DEBIAN_FRONTEND=noninteractive +# Required: path (relative to build context) to the pre-built gateway .deb. +ARG GATEWAY_DEB_REL_PATH + +# Fail fast when the caller forgets the deb path. +RUN [ -n "${GATEWAY_DEB_REL_PATH}" ] || \ + (echo "ERROR: GATEWAY_DEB_REL_PATH build-arg is required." >&2; exit 1) + +# ---------- minimal runtime deps + PG client (psql, pg_isready) ---------- +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + jq \ + openssl \ + postgresql-client && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# ---------- gateway binary (from deb) ---------- +COPY ${GATEWAY_DEB_REL_PATH} /tmp/gateway.deb +RUN dpkg -i /tmp/gateway.deb && \ + rm -f /tmp/gateway.deb + +# ---------- runtime config & scripts ---------- +RUN useradd -ms /bin/bash -u 1000 documentdb + +RUN mkdir -p /home/documentdb/gateway/scripts \ + && mkdir -p /home/documentdb/gateway/pg_documentdb_gw/target + +COPY pg_documentdb_gw/SetupConfiguration.json \ + /home/documentdb/gateway/pg_documentdb_gw/SetupConfiguration.json +COPY scripts/utils.sh \ + /home/documentdb/gateway/scripts/utils.sh +COPY scripts/gateway_entrypoint.sh \ + /home/documentdb/gateway/scripts/gateway_entrypoint.sh + +RUN chown -R documentdb:documentdb /home/documentdb/gateway \ + && chmod +x /home/documentdb/gateway/scripts/*.sh + +USER documentdb +WORKDIR /home/documentdb/gateway + +ENTRYPOINT ["/bin/bash", "/home/documentdb/gateway/scripts/gateway_entrypoint.sh"] diff --git a/.github/dockerfiles/gateway_entrypoint.sh b/.github/dockerfiles/gateway_entrypoint.sh new file mode 100644 index 00000000..dedcdcab --- /dev/null +++ b/.github/dockerfiles/gateway_entrypoint.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Lean gateway entrypoint for CNPG sidecar mode. +# Handles args passed by the sidecar-injector plugin: +# --create-user, --start-pg, --pg-port, --cert-path, --key-file +set -e + +CREATE_USER="true" +PG_PORT="5432" +CERT_PATH="" +KEY_FILE="" +# In CNPG clusters the superuser is always "postgres" +OWNER="${OWNER:-postgres}" +USERNAME="${USERNAME:-}" +PASSWORD="${PASSWORD:-}" + +while [[ $# -gt 0 ]]; do + case $1 in + --create-user) shift; CREATE_USER="$1"; shift;; + --start-pg) shift; shift;; # ignored — PG managed by CNPG + --pg-port) shift; PG_PORT="$1"; shift;; + --cert-path) shift; CERT_PATH="$1"; shift;; + --key-file) shift; KEY_FILE="$1"; shift;; + --owner) shift; OWNER="$1"; shift;; + --username) shift; USERNAME="$1"; shift;; + --password) shift; PASSWORD="$1"; shift;; + *) echo "Unknown option: $1" >&2; shift;; + esac +done + +# PG is in a separate container; force TCP connection via localhost +export PGHOST=localhost + +# Set up gateway configuration +CONFIG="/home/documentdb/gateway/pg_documentdb_gw/target/SetupConfiguration_temp.json" +cp /home/documentdb/gateway/pg_documentdb_gw/SetupConfiguration.json "$CONFIG" + +if ! [[ "$PG_PORT" =~ ^[0-9]+$ ]]; then + echo "ERROR: PG_PORT must be a number, got: $PG_PORT" >&2 + exit 1 +fi +jq --argjson port "$PG_PORT" '.PostgresPort = $port' "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" + +if [ -n "$CERT_PATH" ] && [ -n "$KEY_FILE" ]; then + jq --arg c "$CERT_PATH" --arg k "$KEY_FILE" \ + '.CertificateOptions = {"CertType":"PemFile","FilePath":$c,"KeyFilePath":$k}' \ + "$CONFIG" > "$CONFIG.tmp" && mv "$CONFIG.tmp" "$CONFIG" +fi + +# Wait for PostgreSQL (TCP readiness check) +echo "Waiting for PostgreSQL on localhost:$PG_PORT..." +timeout=600; elapsed=0 +while ! pg_isready -h localhost -p "$PG_PORT" -q 2>/dev/null; do + if [ "$elapsed" -ge "$timeout" ]; then + echo "PostgreSQL did not become ready within ${timeout}s"; exit 1 + fi + sleep 2; elapsed=$((elapsed + 2)) +done +echo "PostgreSQL is ready." + +# Create admin user if requested +if [ "$CREATE_USER" = "true" ] && [ -n "$USERNAME" ] && [ -n "$PASSWORD" ]; then + echo "Creating admin user $USERNAME..." + source /home/documentdb/gateway/scripts/utils.sh + SetupCustomAdminUser "$USERNAME" "$PASSWORD" "$PG_PORT" "$OWNER" +fi + +# Start gateway +exec /usr/bin/documentdb_gateway "$CONFIG" diff --git a/.github/workflows/test-upgrade-and-rollback.yml b/.github/workflows/test-upgrade-and-rollback.yml new file mode 100644 index 00000000..d2f9d345 --- /dev/null +++ b/.github/workflows/test-upgrade-and-rollback.yml @@ -0,0 +1,676 @@ +name: TEST - Upgrade and Rollback + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + schedule: + - cron: '0 2 * * *' + workflow_dispatch: + inputs: + image_tag: + description: 'Optional: Use existing image tag instead of building locally' + required: false + type: string + workflow_call: + inputs: + image_tag: + description: 'Optional: Use existing image tag instead of building locally' + required: false + type: string + +permissions: + contents: read + actions: read + packages: read + +env: + CERT_MANAGER_NS: cert-manager + OPERATOR_NS: documentdb-operator + DB_NS: documentdb-upgrade-test + DB_NAME: documentdb-upgrade + DB_USERNAME: k8s_secret_user + DB_PASSWORD: K8sSecret100 + DB_PORT: 10260 + +jobs: + build: + name: Build Images and Charts + if: ${{ (inputs.image_tag == '' || inputs.image_tag == null) || github.event_name == 'pull_request' }} + uses: ./.github/workflows/test-build-and-package.yml + with: + version: '0.1.1' + secrets: inherit + + upgrade-and-rollback-test: + name: Upgrade & Rollback (${{ matrix.architecture }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + needs: build + if: always() && (needs.build.result == 'success' || needs.build.result == 'skipped') + + strategy: + matrix: + include: + - architecture: amd64 + runner: ubuntu-22.04 + test_scenario_name: "single-node" + node_count: 1 + instances_per_node: 1 + - architecture: arm64 + runner: ubuntu-22.04-arm + test_scenario_name: "single-node" + node_count: 1 + instances_per_node: 1 + + env: + IMAGE_TAG: ${{ (github.event_name == 'pull_request' || inputs.image_tag == '' || inputs.image_tag == null) && needs.build.outputs.image_tag || inputs.image_tag }} + CHART_VERSION: ${{ needs.build.outputs.chart_version || '0.1.0' }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download artifacts + if: ${{ (inputs.image_tag == '' || inputs.image_tag == null) || github.event_name == 'pull_request' }} + uses: actions/download-artifact@v4 + with: + pattern: 'build-*' + path: ./artifacts + + - name: Determine new DocumentDB and Gateway image references + run: | + # Keep upgrade/rollback workflow self-contained by using images from this run. + if [[ "${{ github.event_name }}" == "pull_request" || -z "${{ inputs.image_tag }}" ]]; then + NEW_DOCDB="ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/documentdb:${{ env.IMAGE_TAG }}-${{ matrix.architecture }}" + NEW_GW="ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/gateway:${{ env.IMAGE_TAG }}-${{ matrix.architecture }}" + else + NEW_DOCDB="ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/documentdb:${{ env.IMAGE_TAG }}" + NEW_GW="ghcr.io/${{ github.repository_owner }}/documentdb-kubernetes-operator/gateway:${{ env.IMAGE_TAG }}" + fi + OLD_DOCDB="$NEW_DOCDB" + OLD_GW="$NEW_GW" + echo "DOCUMENTDB_IMAGE=$NEW_DOCDB" >> $GITHUB_ENV + echo "GATEWAY_IMAGE=$NEW_GW" >> $GITHUB_ENV + echo "DOCUMENTDB_OLD_IMAGE=$OLD_DOCDB" >> $GITHUB_ENV + echo "GATEWAY_OLD_IMAGE=$OLD_GW" >> $GITHUB_ENV + echo "Old DocumentDB image: $OLD_DOCDB" + echo "Old Gateway image: $OLD_GW" + echo "New DocumentDB image: $NEW_DOCDB" + echo "New Gateway image: $NEW_GW" + + - name: Log test configuration + run: | + echo "## Upgrade & Rollback Test Configuration" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [[ -n "${{ inputs.image_tag }}" ]]; then + echo "- **Mode**: Using provided image tag" >> $GITHUB_STEP_SUMMARY + echo "- **Image Tag**: \`${{ inputs.image_tag }}\`" >> $GITHUB_STEP_SUMMARY + else + echo "- **Mode**: Using locally built images" >> $GITHUB_STEP_SUMMARY + echo "- **Image Tag**: \`${{ env.IMAGE_TAG }}\`" >> $GITHUB_STEP_SUMMARY + fi + echo "- **Architecture**: \`${{ matrix.architecture }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Old Extension Image**: \`${{ env.DOCUMENTDB_OLD_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **New Extension Image**: \`${{ env.DOCUMENTDB_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **Old Gateway Image**: \`${{ env.GATEWAY_OLD_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY + echo "- **New Gateway Image**: \`${{ env.GATEWAY_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY + + - name: Setup test environment + uses: ./.github/actions/setup-test-environment + with: + test-type: 'e2e' + architecture: ${{ matrix.architecture }} + runner: ${{ matrix.runner }} + test-scenario-name: ${{ matrix.test_scenario_name }} + node-count: '${{ matrix.node_count }}' + instances-per-node: '${{ matrix.instances_per_node }}' + cert-manager-namespace: ${{ env.CERT_MANAGER_NS }} + operator-namespace: ${{ env.OPERATOR_NS }} + db-namespace: ${{ env.DB_NS }} + db-cluster-name: ${{ env.DB_NAME }} + db-username: ${{ env.DB_USERNAME }} + db-password: ${{ env.DB_PASSWORD }} + db-port: ${{ env.DB_PORT }} + image-tag: ${{ env.IMAGE_TAG }} + chart-version: ${{ env.CHART_VERSION }} + documentdb-image: ${{ env.DOCUMENTDB_OLD_IMAGE }} + gateway-image: ${{ env.GATEWAY_OLD_IMAGE }} + use-external-images: ${{ github.event_name != 'pull_request' && inputs.image_tag != '' && inputs.image_tag != null }} + github-token: ${{ secrets.GITHUB_TOKEN }} + repository-owner: ${{ github.repository_owner }} + + - name: Setup port forwarding for data seeding + uses: ./.github/actions/setup-port-forwarding + with: + namespace: ${{ env.DB_NS }} + cluster-name: ${{ env.DB_NAME }} + port: ${{ env.DB_PORT }} + architecture: ${{ matrix.architecture }} + test-type: 'comprehensive' + + - name: Seed test data before upgrade + run: | + echo "=== Data Persistence: Writing seed data before upgrade ===" + mongosh 127.0.0.1:$DB_PORT \ + -u $DB_USERNAME \ + -p $DB_PASSWORD \ + --authenticationMechanism SCRAM-SHA-256 \ + --tls \ + --tlsAllowInvalidCertificates \ + --eval ' + db = db.getSiblingDB("upgrade_test_db"); + db.test_collection.insertOne({ _id: "upgrade_marker", step: "pre-upgrade", timestamp: new Date().toISOString() }); + db.test_collection.insertOne({ _id: "persistence_check", data: "this_must_survive_rollback", count: 42 }); + var count = db.test_collection.countDocuments(); + print("✓ Seed data written: " + count + " documents"); + assert(count === 2, "Expected 2 documents but found " + count); + ' + echo "✓ Seed data written successfully on old version" + + - name: Cleanup port forwarding after data seeding + if: always() + run: | + if [ -f /tmp/pf_pid ]; then + PF_PID=$(cat /tmp/pf_pid) + kill $PF_PID 2>/dev/null || true + rm -f /tmp/pf_pid + fi + rm -f /tmp/pf_output.log + + - name: "Step 1: Upgrade Both Extension and Gateway Images" + run: | + echo "=== Step 1: Upgrade Both Extension and Gateway Images ===" + echo "Testing simultaneous extension + gateway upgrade on ${{ matrix.architecture }}..." + + OLD_EXTENSION="${{ env.DOCUMENTDB_OLD_IMAGE }}" + NEW_EXTENSION="${{ env.DOCUMENTDB_IMAGE }}" + OLD_GATEWAY="${{ env.GATEWAY_OLD_IMAGE }}" + NEW_GATEWAY="${{ env.GATEWAY_IMAGE }}" + + # Verify baseline: cluster deployed with old images + CURRENT_EXTENSION=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.spec.documentDBImage}') + echo "Current extension image: $CURRENT_EXTENSION" + if [[ "$CURRENT_EXTENSION" != "$OLD_EXTENSION" ]]; then + echo "❌ Expected old extension image $OLD_EXTENSION but found $CURRENT_EXTENSION" + exit 1 + fi + + CURRENT_GATEWAY=$(kubectl get cluster $DB_NAME -n $DB_NS -o jsonpath='{.spec.plugins[0].parameters.gatewayImage}') + echo "Current gateway image: $CURRENT_GATEWAY" + if [[ "$CURRENT_GATEWAY" != "$OLD_GATEWAY" ]]; then + echo "❌ Expected old gateway image $OLD_GATEWAY but found $CURRENT_GATEWAY" + exit 1 + fi + echo "✓ Cluster deployed with old images" + + # Record and verify version before upgrade + VERSION_BEFORE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.schemaVersion}') + echo "DocumentDB schema version before upgrade: $VERSION_BEFORE" + + if [[ -z "$VERSION_BEFORE" ]]; then + echo "❌ status.schemaVersion is empty before upgrade" + exit 1 + fi + echo "✓ DocumentDB schema version is populated before upgrade" + + # Patch both images simultaneously + echo "" + echo "Upgrading both images..." + echo " Extension: $OLD_EXTENSION → $NEW_EXTENSION" + echo " Gateway: $OLD_GATEWAY → $NEW_GATEWAY" + kubectl patch documentdb $DB_NAME -n $DB_NS --type='merge' \ + -p "{\"spec\":{\"documentDBImage\":\"$NEW_EXTENSION\",\"gatewayImage\":\"$NEW_GATEWAY\"}}" + + echo "Waiting for cluster to be healthy with new images..." + timeout 600 bash -c ' + while true; do + DB_STATUS=$(kubectl get documentdb '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.status}" 2>/dev/null) + CLUSTER_STATUS=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.phase}" 2>/dev/null) + SCHEMA_VERSION=$(kubectl get documentdb '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.schemaVersion}" 2>/dev/null || echo "N/A") + echo "DocumentDB status: $DB_STATUS, CNPG phase: $CLUSTER_STATUS, schemaVersion: $SCHEMA_VERSION" + if [[ "$DB_STATUS" == "Cluster in healthy state" && "$CLUSTER_STATUS" == "Cluster in healthy state" ]]; then + HEALTHY_PODS=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.instancesStatus.healthy}" 2>/dev/null | jq length 2>/dev/null || echo "0") + if [[ "$HEALTHY_PODS" -ge "1" ]]; then + # Verify pods are actually running the new extension image + # With ImageVolume (K8s >= 1.35), the extension image is mounted as a volume, not an init container + POD_IMAGES=$(kubectl get pods -n '$DB_NS' -l cnpg.io/cluster='$DB_NAME' -o jsonpath="{.items[*].spec.volumes[*].image.reference}" 2>/dev/null) + if echo "$POD_IMAGES" | grep -q "'"$NEW_EXTENSION"'"; then + echo "✓ Cluster healthy with $HEALTHY_PODS pods running new images" + break + else + echo "Pods not yet running new extension image, waiting..." + fi + fi + fi + sleep 10 + done + ' + + # Verify extension image + FINAL_EXTENSION=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.spec.documentDBImage}') + if [[ "$FINAL_EXTENSION" != "$NEW_EXTENSION" ]]; then + echo "❌ Extension image not applied: expected $NEW_EXTENSION, got $FINAL_EXTENSION" + exit 1 + fi + echo "✓ Extension image upgraded to $NEW_EXTENSION" + + # Verify gateway image in CNPG cluster + FINAL_GATEWAY=$(kubectl get cluster $DB_NAME -n $DB_NS -o jsonpath='{.spec.plugins[0].parameters.gatewayImage}') + if [[ "$FINAL_GATEWAY" != "$NEW_GATEWAY" ]]; then + echo "❌ Gateway image not applied: expected $NEW_GATEWAY, got $FINAL_GATEWAY" + exit 1 + fi + echo "✓ Gateway image upgraded to $NEW_GATEWAY" + + # Verify DocumentDB schema version updated + VERSION_AFTER=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.schemaVersion}') + echo "DocumentDB schema version after upgrade: $VERSION_AFTER" + if [[ -z "$VERSION_AFTER" ]]; then + echo "❌ status.schemaVersion is empty after upgrade" + exit 1 + fi + + # Verify status fields + echo "" + echo "=== Status Field Verification ===" + STATUS_DB_IMAGE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.documentDBImage}') + echo "status.documentDBImage: $STATUS_DB_IMAGE" + if [[ "$STATUS_DB_IMAGE" == "$NEW_EXTENSION" ]]; then + echo "✓ status.documentDBImage matches new extension image" + else + echo "⚠️ status.documentDBImage ($STATUS_DB_IMAGE) does not match expected ($NEW_EXTENSION)" + fi + + STATUS_GATEWAY_IMAGE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.gatewayImage}') + echo "status.gatewayImage: $STATUS_GATEWAY_IMAGE" + if [[ "$STATUS_GATEWAY_IMAGE" == "$NEW_GATEWAY" ]]; then + echo "✓ status.gatewayImage matches new gateway image" + else + echo "⚠️ status.gatewayImage ($STATUS_GATEWAY_IMAGE) does not match expected ($NEW_GATEWAY)" + fi + + echo "" + echo "✅ Step 1 passed: Both images upgraded successfully" + echo " Extension: $OLD_EXTENSION → $NEW_EXTENSION" + echo " Gateway: $OLD_GATEWAY → $NEW_GATEWAY" + + - name: Setup port forwarding for upgrade verification + uses: ./.github/actions/setup-port-forwarding + with: + namespace: ${{ env.DB_NS }} + cluster-name: ${{ env.DB_NAME }} + port: ${{ env.DB_PORT }} + architecture: ${{ matrix.architecture }} + test-type: 'comprehensive' + + - name: Verify data persistence after upgrade + run: | + echo "=== Data Persistence: Verifying after upgrade ===" + mongosh 127.0.0.1:$DB_PORT \ + -u $DB_USERNAME \ + -p $DB_PASSWORD \ + --authenticationMechanism SCRAM-SHA-256 \ + --tls \ + --tlsAllowInvalidCertificates \ + --eval ' + db = db.getSiblingDB("upgrade_test_db"); + var count = db.test_collection.countDocuments(); + assert(count === 2, "Expected 2 documents but found " + count + " after upgrade"); + print("✓ All " + count + " documents persisted through upgrade"); + ' + echo "✓ Data persistence verified after upgrade" + + - name: Cleanup port forwarding after upgrade verification + if: always() + run: | + if [ -f /tmp/pf_pid ]; then + PF_PID=$(cat /tmp/pf_pid) + kill $PF_PID 2>/dev/null || true + rm -f /tmp/pf_pid + fi + rm -f /tmp/pf_output.log + + - name: "Step 2: Rollback Extension Image (gateway stays at new version)" + run: | + echo "=== Step 2: Rollback Extension Image ===" + echo "Rolling back extension image while keeping gateway at new version..." + + OLD_EXTENSION="${{ env.DOCUMENTDB_OLD_IMAGE }}" + NEW_EXTENSION="${{ env.DOCUMENTDB_IMAGE }}" + NEW_GATEWAY="${{ env.GATEWAY_IMAGE }}" + + # Record state before rollback + VERSION_BEFORE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.schemaVersion}') + echo "DocumentDB schema version before extension rollback: $VERSION_BEFORE" + + EVENTS_BEFORE=$(kubectl get events -n $DB_NS --field-selector reason=ExtensionRollback --no-headers 2>/dev/null | wc -l || echo "0") + echo "ExtensionRollback events before: $EVENTS_BEFORE" + + # Rollback only extension image + echo "" + echo "Patching spec.documentDBImage: $NEW_EXTENSION → $OLD_EXTENSION" + kubectl patch documentdb $DB_NAME -n $DB_NS --type='merge' \ + -p "{\"spec\":{\"documentDBImage\":\"$OLD_EXTENSION\"}}" + + echo "Waiting for cluster to stabilize after extension rollback..." + timeout 600 bash -c ' + while true; do + DB_STATUS=$(kubectl get documentdb '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.status}" 2>/dev/null) + CLUSTER_STATUS=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.phase}" 2>/dev/null) + echo "DocumentDB status: $DB_STATUS, CNPG phase: $CLUSTER_STATUS" + if [[ "$DB_STATUS" == "Cluster in healthy state" && "$CLUSTER_STATUS" == "Cluster in healthy state" ]]; then + HEALTHY_PODS=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.instancesStatus.healthy}" 2>/dev/null | jq length 2>/dev/null || echo "0") + if [[ "$HEALTHY_PODS" -ge "1" ]]; then + # Verify pods are running the rolled-back extension image + # With ImageVolume (K8s >= 1.35), the extension image is mounted as a volume, not an init container + POD_IMAGES=$(kubectl get pods -n '$DB_NS' -l cnpg.io/cluster='$DB_NAME' -o jsonpath="{.items[*].spec.volumes[*].image.reference}" 2>/dev/null) + if echo "$POD_IMAGES" | grep -q "'"$OLD_EXTENSION"'"; then + echo "✓ Cluster healthy with $HEALTHY_PODS pods running rolled-back extension image" + break + else + echo "Pods not yet running rolled-back extension image, waiting..." + fi + fi + fi + sleep 10 + done + ' + + echo "" + echo "=== Extension Rollback Verification ===" + + # Verify extension image rolled back + CURRENT_EXTENSION=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.spec.documentDBImage}') + if [[ "$CURRENT_EXTENSION" == "$OLD_EXTENSION" ]]; then + echo "✓ spec.documentDBImage correctly rolled back to $OLD_EXTENSION" + else + echo "❌ spec.documentDBImage should be $OLD_EXTENSION but is $CURRENT_EXTENSION" + exit 1 + fi + + # Verify schema version preserved (ALTER EXTENSION UPDATE skipped) + VERSION_AFTER=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.schemaVersion}') + echo "DocumentDB schema version before rollback: $VERSION_BEFORE" + echo "DocumentDB schema version after rollback: $VERSION_AFTER" + if [[ "$VERSION_AFTER" == "$VERSION_BEFORE" ]]; then + echo "✓ Schema version preserved — ALTER EXTENSION UPDATE correctly skipped" + else + echo "⚠️ Schema version changed from $VERSION_BEFORE to $VERSION_AFTER" + fi + + # Verify ExtensionRollback warning event (poll up to 60s instead of hardcoded sleep) + echo "Waiting for ExtensionRollback event..." + EVENTS_AFTER=$EVENTS_BEFORE + for i in $(seq 1 12); do + EVENTS_AFTER=$(kubectl get events -n $DB_NS --field-selector reason=ExtensionRollback --no-headers 2>/dev/null | wc -l || echo "0") + if [[ "$EVENTS_AFTER" -gt "$EVENTS_BEFORE" ]]; then + break + fi + sleep 5 + done + echo "ExtensionRollback events after: $EVENTS_AFTER" + if [[ "$EVENTS_AFTER" -gt "$EVENTS_BEFORE" ]]; then + echo "✓ ExtensionRollback warning event detected" + kubectl get events -n $DB_NS --field-selector reason=ExtensionRollback + else + echo "⚠️ No new ExtensionRollback event detected within 60s" + kubectl get events -n $DB_NS --sort-by='.lastTimestamp' | tail -20 + fi + + # Verify gateway image UNCHANGED at new version + CURRENT_GATEWAY=$(kubectl get cluster $DB_NAME -n $DB_NS -o jsonpath='{.spec.plugins[0].parameters.gatewayImage}') + echo "" + echo "Gateway image after extension rollback: $CURRENT_GATEWAY" + if [[ "$CURRENT_GATEWAY" == "$NEW_GATEWAY" ]]; then + echo "✓ Gateway image unchanged at $NEW_GATEWAY (extension rollback did not affect gateway)" + else + echo "❌ Gateway image changed unexpectedly: expected $NEW_GATEWAY, got $CURRENT_GATEWAY" + exit 1 + fi + + # Verify status fields + STATUS_DB_IMAGE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.documentDBImage}') + echo "status.documentDBImage: $STATUS_DB_IMAGE" + if [[ "$STATUS_DB_IMAGE" == "$OLD_EXTENSION" ]]; then + echo "✓ status.documentDBImage reflects rolled-back extension" + else + echo "⚠️ status.documentDBImage ($STATUS_DB_IMAGE) does not match $OLD_EXTENSION" + fi + + STATUS_GATEWAY_IMAGE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.gatewayImage}') + echo "status.gatewayImage: $STATUS_GATEWAY_IMAGE" + if [[ "$STATUS_GATEWAY_IMAGE" == "$NEW_GATEWAY" ]]; then + echo "✓ status.gatewayImage still at new gateway version" + else + echo "⚠️ status.gatewayImage ($STATUS_GATEWAY_IMAGE) does not match $NEW_GATEWAY" + fi + + echo "" + echo "✅ Step 2 passed: Extension rolled back, gateway unchanged" + echo " Extension: $NEW_EXTENSION → $OLD_EXTENSION (rolled back)" + echo " Gateway: $NEW_GATEWAY (unchanged)" + + - name: Setup port forwarding for extension rollback verification + uses: ./.github/actions/setup-port-forwarding + with: + namespace: ${{ env.DB_NS }} + cluster-name: ${{ env.DB_NAME }} + port: ${{ env.DB_PORT }} + architecture: ${{ matrix.architecture }} + test-type: 'comprehensive' + + - name: Verify data persistence after extension rollback + run: | + echo "=== Data Persistence: Verifying after extension rollback ===" + mongosh 127.0.0.1:$DB_PORT \ + -u $DB_USERNAME \ + -p $DB_PASSWORD \ + --authenticationMechanism SCRAM-SHA-256 \ + --tls \ + --tlsAllowInvalidCertificates \ + --eval ' + db = db.getSiblingDB("upgrade_test_db"); + var count = db.test_collection.countDocuments(); + assert(count === 2, "Expected 2 documents but found " + count + " after extension rollback"); + print("✓ All " + count + " documents persisted through extension rollback"); + ' + echo "✓ Data persistence verified after extension rollback" + + - name: Cleanup port forwarding after extension rollback verification + if: always() + run: | + if [ -f /tmp/pf_pid ]; then + PF_PID=$(cat /tmp/pf_pid) + kill $PF_PID 2>/dev/null || true + rm -f /tmp/pf_pid + fi + rm -f /tmp/pf_output.log + + - name: "Step 3: Rollback Gateway Image (extension stays at old version)" + run: | + echo "=== Step 3: Rollback Gateway Image ===" + echo "Rolling back gateway image while keeping extension at old version..." + + OLD_EXTENSION="${{ env.DOCUMENTDB_OLD_IMAGE }}" + OLD_GATEWAY="${{ env.GATEWAY_OLD_IMAGE }}" + NEW_GATEWAY="${{ env.GATEWAY_IMAGE }}" + + # Record state before gateway rollback + VERSION_BEFORE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.schemaVersion}') + echo "DocumentDB schema version before gateway rollback: $VERSION_BEFORE" + + # Rollback only gateway image + echo "" + echo "Patching spec.gatewayImage: $NEW_GATEWAY → $OLD_GATEWAY" + kubectl patch documentdb $DB_NAME -n $DB_NS --type='merge' \ + -p "{\"spec\":{\"gatewayImage\":\"$OLD_GATEWAY\"}}" + + echo "Waiting for cluster to stabilize after gateway rollback..." + timeout 600 bash -c ' + while true; do + DB_STATUS=$(kubectl get documentdb '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.status}" 2>/dev/null) + CLUSTER_STATUS=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.phase}" 2>/dev/null) + echo "DocumentDB status: $DB_STATUS, CNPG phase: $CLUSTER_STATUS" + if [[ "$DB_STATUS" == "Cluster in healthy state" && "$CLUSTER_STATUS" == "Cluster in healthy state" ]]; then + HEALTHY_PODS=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.status.instancesStatus.healthy}" 2>/dev/null | jq length 2>/dev/null || echo "0") + if [[ "$HEALTHY_PODS" -ge "1" ]]; then + # Verify gateway plugin parameter reflects the rolled-back image + CURRENT_GW_PARAM=$(kubectl get cluster '$DB_NAME' -n '$DB_NS' -o jsonpath="{.spec.plugins[0].parameters.gatewayImage}" 2>/dev/null) + if [[ "$CURRENT_GW_PARAM" == "'"$OLD_GATEWAY"'" ]]; then + echo "✓ Cluster healthy with $HEALTHY_PODS pods and gateway image rolled back" + break + else + echo "Gateway image not yet rolled back in cluster spec, waiting..." + fi + fi + fi + sleep 10 + done + ' + + echo "" + echo "=== Gateway Rollback Verification ===" + + # Verify gateway image rolled back in CNPG cluster + CURRENT_GATEWAY=$(kubectl get cluster $DB_NAME -n $DB_NS -o jsonpath='{.spec.plugins[0].parameters.gatewayImage}') + if [[ "$CURRENT_GATEWAY" == "$OLD_GATEWAY" ]]; then + echo "✓ Gateway image rolled back to $OLD_GATEWAY" + else + echo "❌ Gateway image should be $OLD_GATEWAY but is $CURRENT_GATEWAY" + exit 1 + fi + + # Verify extension image UNCHANGED at old version + CURRENT_EXTENSION=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.spec.documentDBImage}') + echo "Extension image after gateway rollback: $CURRENT_EXTENSION" + if [[ "$CURRENT_EXTENSION" == "$OLD_EXTENSION" ]]; then + echo "✓ Extension image unchanged at $OLD_EXTENSION (gateway rollback did not affect extension)" + else + echo "❌ Extension image changed unexpectedly: expected $OLD_EXTENSION, got $CURRENT_EXTENSION" + exit 1 + fi + + # Verify schema version unchanged (gateway is stateless, no schema impact) + VERSION_AFTER=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.schemaVersion}') + echo "DocumentDB schema version before gateway rollback: $VERSION_BEFORE" + echo "DocumentDB schema version after gateway rollback: $VERSION_AFTER" + if [[ "$VERSION_AFTER" == "$VERSION_BEFORE" ]]; then + echo "✓ Schema version unchanged — gateway rollback has no schema impact" + else + echo "⚠️ Schema version changed unexpectedly from $VERSION_BEFORE to $VERSION_AFTER" + fi + + # Verify status fields + STATUS_DB_IMAGE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.documentDBImage}') + echo "status.documentDBImage: $STATUS_DB_IMAGE" + if [[ "$STATUS_DB_IMAGE" == "$OLD_EXTENSION" ]]; then + echo "✓ status.documentDBImage still at old extension" + else + echo "⚠️ status.documentDBImage ($STATUS_DB_IMAGE) does not match $OLD_EXTENSION" + fi + + STATUS_GATEWAY_IMAGE=$(kubectl get documentdb $DB_NAME -n $DB_NS -o jsonpath='{.status.gatewayImage}') + echo "status.gatewayImage: $STATUS_GATEWAY_IMAGE" + if [[ "$STATUS_GATEWAY_IMAGE" == "$OLD_GATEWAY" ]]; then + echo "✓ status.gatewayImage reflects rolled-back gateway" + else + echo "⚠️ status.gatewayImage ($STATUS_GATEWAY_IMAGE) does not match $OLD_GATEWAY" + fi + + echo "" + echo "✅ Step 3 passed: Gateway rolled back, extension unchanged" + echo " Extension: $OLD_EXTENSION (unchanged)" + echo " Gateway: $NEW_GATEWAY → $OLD_GATEWAY (rolled back)" + + - name: Setup port forwarding for gateway rollback verification + uses: ./.github/actions/setup-port-forwarding + with: + namespace: ${{ env.DB_NS }} + cluster-name: ${{ env.DB_NAME }} + port: ${{ env.DB_PORT }} + architecture: ${{ matrix.architecture }} + test-type: 'comprehensive' + + - name: Verify data persistence after gateway rollback + run: | + echo "=== Data Persistence: Verifying after gateway rollback ===" + mongosh 127.0.0.1:$DB_PORT \ + -u $DB_USERNAME \ + -p $DB_PASSWORD \ + --authenticationMechanism SCRAM-SHA-256 \ + --tls \ + --tlsAllowInvalidCertificates \ + --eval ' + db = db.getSiblingDB("upgrade_test_db"); + var count = db.test_collection.countDocuments(); + assert(count === 2, "Expected 2 documents but found " + count + " after gateway rollback"); + print("✓ All " + count + " documents persisted through full upgrade/rollback cycle"); + ' + echo "✓ Data persistence verified after gateway rollback" + + - name: Cleanup port forwarding after gateway rollback verification + if: always() + run: | + if [ -f /tmp/pf_pid ]; then + PF_PID=$(cat /tmp/pf_pid) + kill $PF_PID 2>/dev/null || true + rm -f /tmp/pf_pid + fi + rm -f /tmp/pf_output.log + + - name: Collect comprehensive logs on failure + if: failure() + uses: ./.github/actions/collect-logs + with: + architecture: ${{ matrix.architecture }} + operator-namespace: ${{ env.OPERATOR_NS }} + db-namespace: ${{ env.DB_NS }} + db-name: ${{ env.DB_NAME }} + + - name: Test completion summary + if: always() + run: | + echo "## Upgrade & Rollback Test Summary for ${{ matrix.architecture }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Architecture**: ${{ matrix.architecture }}" >> $GITHUB_STEP_SUMMARY + echo "- **Old Extension Image**: ${{ env.DOCUMENTDB_OLD_IMAGE }}" >> $GITHUB_STEP_SUMMARY + echo "- **New Extension Image**: ${{ env.DOCUMENTDB_IMAGE }}" >> $GITHUB_STEP_SUMMARY + echo "- **Old Gateway Image**: ${{ env.GATEWAY_OLD_IMAGE }}" >> $GITHUB_STEP_SUMMARY + echo "- **New Gateway Image**: ${{ env.GATEWAY_IMAGE }}" >> $GITHUB_STEP_SUMMARY + echo "- **Image Tag**: ${{ env.IMAGE_TAG }}" >> $GITHUB_STEP_SUMMARY + echo "- **Chart Version**: ${{ env.CHART_VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ job.status }}" == "success" ]]; then + echo "- **Status**: ✅ PASSED" >> $GITHUB_STEP_SUMMARY + else + echo "- **Status**: ❌ FAILED" >> $GITHUB_STEP_SUMMARY + fi + + test-summary: + name: Upgrade & Rollback Test Summary + runs-on: ubuntu-latest + if: always() + needs: [build, upgrade-and-rollback-test] + steps: + - name: Generate overall test summary + run: | + echo "## Upgrade & Rollback Test Results Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Test Configuration:" >> $GITHUB_STEP_SUMMARY + echo "- **Build Step**: ${{ inputs.image_tag && 'Skipped (using external images)' || 'Executed' }}" >> $GITHUB_STEP_SUMMARY + echo "- **Image Tag**: ${{ inputs.image_tag || 'Built from source' }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Job Results:" >> $GITHUB_STEP_SUMMARY + echo "- **Build**: ${{ needs.build.result }}" >> $GITHUB_STEP_SUMMARY + echo "- **Upgrade & Rollback Tests**: ${{ needs.upgrade-and-rollback-test.result }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [[ "${{ needs.upgrade-and-rollback-test.result }}" == "success" ]]; then + echo "### Overall Status: ✅ ALL TESTS PASSED" >> $GITHUB_STEP_SUMMARY + else + echo "### Overall Status: ❌ SOME TESTS FAILED" >> $GITHUB_STEP_SUMMARY + echo "Check individual job results above for details." >> $GITHUB_STEP_SUMMARY + fi diff --git a/docs/crd-ref-docs-config.yaml b/docs/crd-ref-docs-config.yaml new file mode 100644 index 00000000..6e5f1139 --- /dev/null +++ b/docs/crd-ref-docs-config.yaml @@ -0,0 +1,24 @@ +processor: + # Exclude list types and status types from the generated documentation. + # Status types (TLSStatus, DocumentDBStatus) are read-only runtime state set by the operator. + # Since the status field is excluded via ignoreFields, excluding the status types + # prevents broken cross-references (e.g., TLSStatus "Appears in: DocumentDBStatus"). + ignoreTypes: + - "List$" + - "Status$" + # Exclude status and TypeMeta fields + ignoreFields: + - "status$" + - "TypeMeta$" + +render: + # Kubernetes version for linking to K8s API docs + kubernetesVersion: 1.35 + # Link external types to their upstream documentation + knownTypes: + - name: LocalObjectReference + package: github.com/cloudnative-pg/cloudnative-pg/api/v1 + link: https://pkg.go.dev/github.com/cloudnative-pg/cloudnative-pg/api/v1#LocalObjectReference + - name: AffinityConfiguration + package: github.com/cloudnative-pg/cloudnative-pg/api/v1 + link: https://pkg.go.dev/github.com/cloudnative-pg/cloudnative-pg/api/v1#AffinityConfiguration diff --git a/docs/operator-public-documentation/preview/api-reference.md b/docs/operator-public-documentation/preview/api-reference.md new file mode 100644 index 00000000..73f5144e --- /dev/null +++ b/docs/operator-public-documentation/preview/api-reference.md @@ -0,0 +1,423 @@ +# API Reference + +## Packages +- [documentdb.io/preview](#documentdbiopreview) + + +## documentdb.io/preview + +Package preview contains API Schema definitions for the db preview API group. + +### Resource Types +- [Backup](#backup) +- [DocumentDB](#documentdb) +- [ScheduledBackup](#scheduledbackup) + + + +#### Backup + + + + + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `documentdb.io/preview` | | | +| `kind` _string_ | `Backup` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[BackupSpec](#backupspec)_ | | | | + + +#### BackupConfiguration + + + +BackupConfiguration defines backup settings for DocumentDB. + + + +_Appears in:_ +- [DocumentDBSpec](#documentdbspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `retentionDays` _integer_ | RetentionDays specifies how many days backups should be retained.
If not specified, the default retention period is 30 days. | 30 | Maximum: 365
Minimum: 1
Optional: \{\}
| + + +#### BackupSpec + + + +BackupSpec defines the desired state of Backup. + + + +_Appears in:_ +- [Backup](#backup) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `cluster` _[LocalObjectReference](https://pkg.go.dev/github.com/cloudnative-pg/cloudnative-pg/api/v1#LocalObjectReference)_ | Cluster specifies the DocumentDB cluster to backup.
The cluster must exist in the same namespace as the Backup resource. | | Required: \{\}
| +| `retentionDays` _integer_ | RetentionDays specifies how many days the backup should be retained.
If not specified, the default retention period from the cluster's backup retention policy will be used. | | Optional: \{\}
| + + +#### BootstrapConfiguration + + + +BootstrapConfiguration defines how to bootstrap a DocumentDB cluster. + + + +_Appears in:_ +- [DocumentDBSpec](#documentdbspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `recovery` _[RecoveryConfiguration](#recoveryconfiguration)_ | Recovery configures recovery from a backup. | | Optional: \{\}
| + + +#### CertManagerTLS + + + +CertManagerTLS holds parameters for cert-manager driven certificates. + + + +_Appears in:_ +- [GatewayTLS](#gatewaytls) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `issuerRef` _[IssuerRef](#issuerref)_ | | | | +| `dnsNames` _string array_ | DNSNames for the certificate SANs. If empty, operator will add Service DNS names. | | | +| `secretName` _string_ | SecretName optional explicit name for the target secret. If empty a default is chosen. | | | + + +#### ClusterReplication + + + + + + + +_Appears in:_ +- [DocumentDBSpec](#documentdbspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `crossCloudNetworkingStrategy` _string_ | CrossCloudNetworking determines which type of networking mechanics for the replication | | Enum: [AzureFleet Istio None]
| +| `primary` _string_ | Primary is the name of the primary cluster for replication. | | | +| `clusterList` _[MemberCluster](#membercluster) array_ | ClusterList is the list of clusters participating in replication. | | | +| `highAvailability` _boolean_ | Whether or not to have replicas on the primary cluster. | | | + + +#### DocumentDB + + + +DocumentDB is the Schema for the dbs API. + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `documentdb.io/preview` | | | +| `kind` _string_ | `DocumentDB` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[DocumentDBSpec](#documentdbspec)_ | | | | + + +#### DocumentDBSpec + + + +DocumentDBSpec defines the desired state of DocumentDB. + + + +_Appears in:_ +- [DocumentDB](#documentdb) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `nodeCount` _integer_ | NodeCount is the number of nodes in the DocumentDB cluster. Must be 1. | | Maximum: 1
Minimum: 1
| +| `instancesPerNode` _integer_ | InstancesPerNode is the number of DocumentDB instances per node. Range: 1-3. | | Maximum: 3
Minimum: 1
| +| `resource` _[Resource](#resource)_ | Resource specifies the storage resources for DocumentDB. | | | +| `documentDBVersion` _string_ | DocumentDBVersion specifies the version for all DocumentDB components (engine, gateway).
When set, this overrides the default versions for documentDBImage and gatewayImage.
Individual image fields take precedence over this version. | | | +| `documentDBImage` _string_ | DocumentDBImage is the container image to use for DocumentDB.
Changing this is not recommended for most users.
If not specified, defaults based on documentDBVersion or operator defaults. | | | +| `gatewayImage` _string_ | GatewayImage is the container image to use for the DocumentDB Gateway sidecar.
Changing this is not recommended for most users.
If not specified, defaults to a version that matches the DocumentDB operator version. | | | +| `postgresImage` _string_ | PostgresImage is the container image to use for the PostgreSQL server.
If not specified, defaults to the last stable PostgreSQL version compatible with DocumentDB.
Must use trixie (Debian 13) base to match the extension's GLIBC requirements. | ghcr.io/cloudnative-pg/postgresql:18-minimal-trixie | Optional: \{\}
| +| `documentDbCredentialSecret` _string_ | DocumentDbCredentialSecret is the name of the Kubernetes Secret containing credentials
for the DocumentDB gateway (expects keys `username` and `password`). If omitted,
a default secret name `documentdb-credentials` is used. | | | +| `clusterReplication` _[ClusterReplication](#clusterreplication)_ | ClusterReplication configures cross-cluster replication for DocumentDB. | | | +| `sidecarInjectorPluginName` _string_ | SidecarInjectorPluginName is the name of the sidecar injector plugin to use. | | | +| `walReplicaPluginName` _string_ | WalReplicaPluginName is the name of the wal replica plugin to use. | | | +| `exposeViaService` _[ExposeViaService](#exposeviaservice)_ | ExposeViaService configures how to expose DocumentDB via a Kubernetes service.
This can be a LoadBalancer or ClusterIP service. | | | +| `environment` _string_ | Environment specifies the cloud environment for deployment
This determines cloud-specific service annotations for LoadBalancer services | | Enum: [eks aks gke]
| +| `timeouts` _[Timeouts](#timeouts)_ | | | | +| `tls` _[TLSConfiguration](#tlsconfiguration)_ | TLS configures certificate management for DocumentDB components. | | | +| `logLevel` _string_ | Overrides default log level for the DocumentDB cluster. | | | +| `bootstrap` _[BootstrapConfiguration](#bootstrapconfiguration)_ | Bootstrap configures the initialization of the DocumentDB cluster. | | Optional: \{\}
| +| `backup` _[BackupConfiguration](#backupconfiguration)_ | Backup configures backup settings for DocumentDB. | | Optional: \{\}
| +| `featureGates` _object (keys:string, values:boolean)_ | FeatureGates enables or disables optional DocumentDB features.
Keys are PascalCase feature names following the Kubernetes feature gate convention.
Example: \{"ChangeStreams": true\}
IMPORTANT: When adding a new feature gate, update ALL of the following:
1. Add a new FeatureGate* constant in documentdb_types.go
2. Add the key name to the XValidation CEL rule's allowed list below
3. Add a default entry in the featureGateDefaults map in documentdb_types.go | | Optional: \{\}
| +| `affinity` _[AffinityConfiguration](https://pkg.go.dev/github.com/cloudnative-pg/cloudnative-pg/api/v1#AffinityConfiguration)_ | Affinity/Anti-affinity rules for Pods (cnpg passthrough) | | Optional: \{\}
| + + +#### ExposeViaService + + + + + + + +_Appears in:_ +- [DocumentDBSpec](#documentdbspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `serviceType` _string_ | ServiceType determines the type of service to expose for DocumentDB. | | Enum: [LoadBalancer ClusterIP]
| + + +#### GatewayTLS + + + +GatewayTLS defines TLS configuration for the gateway sidecar (Phase 1: certificate provisioning only) + + + +_Appears in:_ +- [TLSConfiguration](#tlsconfiguration) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `mode` _string_ | Mode selects the TLS management strategy. | | Enum: [Disabled SelfSigned CertManager Provided]
| +| `certManager` _[CertManagerTLS](#certmanagertls)_ | CertManager config when Mode=CertManager. | | | +| `provided` _[ProvidedTLS](#providedtls)_ | Provided secret reference when Mode=Provided. | | | + + +#### GlobalEndpointsTLS + + + +GlobalEndpointsTLS acts as a placeholder for future global endpoint TLS settings. + + + +_Appears in:_ +- [TLSConfiguration](#tlsconfiguration) + + + +#### IssuerRef + + + +IssuerRef references a cert-manager Issuer or ClusterIssuer. + + + +_Appears in:_ +- [CertManagerTLS](#certmanagertls) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | | | | +| `kind` _string_ | Kind of issuer (Issuer or ClusterIssuer). Defaults to Issuer. | | | +| `group` _string_ | Group defaults to cert-manager.io | | | + + +#### MemberCluster + + + + + + + +_Appears in:_ +- [ClusterReplication](#clusterreplication) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the member cluster. | | | +| `environment` _string_ | EnvironmentOverride is the cloud environment of the member cluster.
Will default to the global setting | | Enum: [eks aks gke]
| +| `storageClass` _string_ | StorageClassOverride specifies the storage class for DocumentDB persistent volumes in this member cluster. | | | + + +#### PVRecoveryConfiguration + + + +PVRecoveryConfiguration defines settings for recovering from a retained PersistentVolume. + + + +_Appears in:_ +- [RecoveryConfiguration](#recoveryconfiguration) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is the name of the PersistentVolume to recover from.
The PV must exist and be in Available or Released state. | | MinLength: 1
| + + +#### PostgresTLS + + + +PostgresTLS acts as a placeholder for future Postgres TLS settings. + + + +_Appears in:_ +- [TLSConfiguration](#tlsconfiguration) + + + +#### ProvidedTLS + + + +ProvidedTLS references an existing secret that contains tls.crt/tls.key (and optional ca.crt). + + + +_Appears in:_ +- [GatewayTLS](#gatewaytls) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `secretName` _string_ | | | | + + +#### RecoveryConfiguration + + + +RecoveryConfiguration defines recovery settings for bootstrapping a DocumentDB cluster. + + + +_Appears in:_ +- [BootstrapConfiguration](#bootstrapconfiguration) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `backup` _[LocalObjectReference](https://pkg.go.dev/github.com/cloudnative-pg/cloudnative-pg/api/v1#LocalObjectReference)_ | Backup specifies the source backup to restore from. | | Optional: \{\}
| +| `persistentVolume` _[PVRecoveryConfiguration](#pvrecoveryconfiguration)_ | PersistentVolume specifies the PV to restore from.
The operator will create a temporary PVC bound to this PV, use it for CNPG recovery,
and delete the temporary PVC after the cluster is healthy.
Cannot be used together with Backup. | | Optional: \{\}
| + + +#### Resource + + + + + + + +_Appears in:_ +- [DocumentDBSpec](#documentdbspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `storage` _[StorageConfiguration](#storageconfiguration)_ | Storage configuration for DocumentDB persistent volumes. | | | + + +#### ScheduledBackup + + + + + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `documentdb.io/preview` | | | +| `kind` _string_ | `ScheduledBackup` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[ScheduledBackupSpec](#scheduledbackupspec)_ | | | | + + +#### ScheduledBackupSpec + + + +ScheduledBackupSpec defines the desired state of ScheduledBackup + + + +_Appears in:_ +- [ScheduledBackup](#scheduledbackup) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `cluster` _[LocalObjectReference](https://pkg.go.dev/github.com/cloudnative-pg/cloudnative-pg/api/v1#LocalObjectReference)_ | Cluster specifies the DocumentDB cluster to backup.
The cluster must exist in the same namespace as the ScheduledBackup resource. | | Required: \{\}
| +| `schedule` _string_ | Schedule defines when backups should be created using cron expression format.
See https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format | | Required: \{\}
| +| `retentionDays` _integer_ | RetentionDays specifies how many days the backups should be retained.
If not specified, the default retention period from the cluster's backup retention policy will be used. | | Optional: \{\}
| + + +#### StorageConfiguration + + + + + + + +_Appears in:_ +- [Resource](#resource) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `pvcSize` _string_ | PvcSize is the size of the persistent volume claim for DocumentDB storage (e.g., "10Gi"). | | | +| `storageClass` _string_ | StorageClass specifies the storage class for DocumentDB persistent volumes.
If not specified, the cluster's default storage class will be used. | | | +| `persistentVolumeReclaimPolicy` _string_ | PersistentVolumeReclaimPolicy controls what happens to the PersistentVolume when
the DocumentDB cluster is deleted.
When a DocumentDB cluster is deleted, the following chain of deletions occurs:
DocumentDB deletion → CNPG Cluster deletion → PVC deletion → PV deletion (based on this policy)
Options:
- Retain (default): The PV is preserved after cluster deletion, allowing manual
data recovery or forensic analysis. Use for production workloads where data
safety is critical. Orphaned PVs must be manually deleted when no longer needed.
- Delete: The PV is automatically deleted when the PVC is deleted. Use for development,
testing, or ephemeral environments where data persistence is not required.
WARNING: Setting this to "Delete" means all data will be permanently lost when
the DocumentDB cluster is deleted. This cannot be undone. | Retain | Enum: [Retain Delete]
Optional: \{\}
| + + +#### TLSConfiguration + + + +TLSConfiguration aggregates TLS settings across DocumentDB components. + + + +_Appears in:_ +- [DocumentDBSpec](#documentdbspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `gateway` _[GatewayTLS](#gatewaytls)_ | Gateway configures TLS for the gateway sidecar (Phase 1: certificate provisioning only). | | | +| `postgres` _[PostgresTLS](#postgrestls)_ | Postgres configures TLS for the Postgres server (placeholder for future phases). | | | +| `globalEndpoints` _[GlobalEndpointsTLS](#globalendpointstls)_ | GlobalEndpoints configures TLS for global endpoints (placeholder for future phases). | | | + + +#### Timeouts + + + + + + + +_Appears in:_ +- [DocumentDBSpec](#documentdbspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `stopDelay` _integer_ | | | Maximum: 1800
Minimum: 0
| + + diff --git a/docs/operator-public-documentation/preview/high-availability/local-ha.md b/docs/operator-public-documentation/preview/high-availability/local-ha.md new file mode 100644 index 00000000..dfee5f01 --- /dev/null +++ b/docs/operator-public-documentation/preview/high-availability/local-ha.md @@ -0,0 +1,289 @@ +--- +title: Local High Availability +description: Configure local high availability for DocumentDB with multiple instances, pod anti-affinity, and automatic failover. +tags: + - high-availability + - configuration + - failover +--- + +# Local High Availability + +Local high availability (HA) deploys multiple DocumentDB instances within a single Kubernetes cluster, providing automatic failover and zero data loss during instance failures. + +## Overview + +Local HA uses synchronous replication between a primary instance and one or two replicas. When the primary fails, a replica is automatically promoted to primary. + +```mermaid +flowchart LR + subgraph zone1[Zone A] + P[Primary] + end + subgraph zone2[Zone B] + R1[Replica 1] + end + subgraph zone3[Zone C] + R2[Replica 2] + end + + App([Application]) --> P + P -->|Sync Replication| R1 + P -->|Sync Replication| R2 +``` + +## Instance Configuration + +Configure the number of instances using the `instancesPerNode` field: + +```yaml title="documentdb-ha.yaml" +apiVersion: documentdb.io/preview +kind: DocumentDB +metadata: + name: my-documentdb + namespace: documentdb +spec: + instancesPerNode: 3 # (1)! + storage: + size: 10Gi + storageClassName: managed-csi +``` + +1. Valid values: `1` (no HA), `2` (primary + 1 replica), `3` (primary + 2 replicas, recommended for production) + +### Instance Count Options + +| Instances | Configuration | Use Case | +|-----------|---------------|----------| +| `1` | Single instance, no replicas | Development, testing | +| `2` | Primary + 1 replica | Cost-sensitive production | +| `3` | Primary + 2 replicas | **Recommended** for production | + +!!! tip "Why 3 instances?" + Three instances provide quorum-based failover. With 2 instances, the system cannot distinguish between a network partition and a failed primary. With 3 instances, the system can achieve consensus and safely promote a replica. + +## Pod Anti-Affinity + +Pod anti-affinity ensures DocumentDB instances are distributed across failure domains (nodes, zones) for resilience. + +### Zone-Level Distribution (Recommended) + +Distribute instances across availability zones: + +```yaml title="documentdb-zone-affinity.yaml" +apiVersion: documentdb.io/preview +kind: DocumentDB +metadata: + name: my-documentdb + namespace: documentdb +spec: + instancesPerNode: 3 + affinity: + enablePodAntiAffinity: true + topologyKey: topology.kubernetes.io/zone # (1)! +``` + +1. Distributes pods across different availability zones. Requires a cluster with nodes in multiple zones. + +### Node-Level Distribution + +For clusters without multiple zones, distribute across nodes: + +```yaml title="documentdb-node-affinity.yaml" +apiVersion: documentdb.io/preview +kind: DocumentDB +metadata: + name: my-documentdb + namespace: documentdb +spec: + instancesPerNode: 3 + affinity: + enablePodAntiAffinity: true + topologyKey: kubernetes.io/hostname # (1)! +``` + +1. Distributes pods across different nodes. Requires at least 3 nodes in the cluster. + +### Affinity Configuration Reference + +| Field | Type | Description | +|-------|------|-------------| +| `enablePodAntiAffinity` | boolean | Enable/disable pod anti-affinity | +| `topologyKey` | string | Kubernetes topology label for distribution | +| `podAntiAffinityType` | string | `preferred` (default) or `required` | + +!!! warning "Required vs Preferred" + Using `required` anti-affinity prevents scheduling if constraints cannot be met. Use `preferred` (default) to allow scheduling even when ideal placement isn't possible. + +## Automatic Failover + +DocumentDB uses CloudNative-PG's failover mechanism to automatically detect primary failure and promote a replica. No manual intervention is required for local HA failover. + +### Failover Timeline + +```mermaid +sequenceDiagram + participant App as Application + participant P as Primary + participant R as Replica + participant Op as Operator + + Note over P: Primary fails + App->>P: Connection fails + Op->>P: Readiness probe fails + Op->>Op: Wait failoverDelay (default: 0s) + Op->>P: Mark TargetPrimary pending + P->>P: Fast shutdown (up to 30s) + Op->>R: Leader election + R->>R: Promote to primary + Op->>App: Update service endpoint + App->>R: Reconnect to new primary + Note over R: New Primary +``` + +### Failover Timing Parameters + +DocumentDB inherits these timing controls from CloudNative-PG: + +| Parameter | Default | Configurable | Description | +|-----------|---------|--------------|-------------| +| `failoverDelay` | 0 seconds | No | Delay before initiating failover after detecting unhealthy primary | +| `stopDelay` | 30 seconds | **Yes** | Time allowed for graceful PostgreSQL shutdown | +| `switchoverDelay` | 3600 seconds | No | Time for primary to gracefully shutdown during planned switchover | +| `livenessProbeTimeout` | 30 seconds | No | Time allowed for liveness probe response | + +!!! note "Current Configuration" + Currently, only `stopDelay` is configurable via `spec.timeouts.stopDelay`. Other parameters use CloudNative-PG default values. Additional timing parameters may be exposed in future releases. + +### Failover Process + +The failover process occurs in two phases: + +**Phase 1: Primary Shutdown** + +1. Readiness probe detects the primary is unhealthy +2. After `failoverDelay` (default: 0s), operator marks `TargetPrimary` as pending +3. Primary pod initiates fast shutdown (up to `stopDelay` seconds) +4. WAL receivers on replicas stop to prevent timeline discrepancies + +**Phase 2: Promotion** + +1. Leader election selects the most up-to-date replica +2. Selected replica promotes to primary and begins accepting writes +3. Kubernetes service endpoints update to point to new primary +4. Former primary restarts as a replica when recovered + +!!! note "Zero Data Loss" + Because replication is synchronous, a committed write exists on at least one replica before acknowledgment. Failover promotes a replica with all committed data. + +### RTO and RPO Impact + +| Scenario | RTO Impact | RPO Impact | +|----------|------------|------------| +| Fast shutdown succeeds | Seconds to tens of seconds | Zero data loss | +| Fast shutdown times out | Up to `stopDelay` (30s default) | Possible data loss | +| Network partition | Depends on quorum | Zero if quorum maintained | + +!!! tip "Tuning for RTO vs RPO" + Lower `stopDelay` values favor faster recovery (RTO) but may increase data loss risk (RPO). Higher values prioritize data safety but may delay recovery. + +## Testing High Availability + +Verify your HA configuration works correctly. + +### Test 1: Verify Instance Distribution + +```bash +# Check pod distribution across zones/nodes +kubectl get pods -n documentdb -l documentdb.io/cluster=my-documentdb \ + -o custom-columns=NAME:.metadata.name,NODE:.spec.nodeName,ZONE:.metadata.labels.topology\\.kubernetes\\.io/zone +``` + +Expected output shows pods on different nodes/zones: +``` +NAME NODE ZONE +my-documentdb-1 node-1 zone-a +my-documentdb-2 node-2 zone-b +my-documentdb-3 node-3 zone-c +``` + +### Test 2: Simulate Failure + +!!! danger "Production Warning" + Only perform failure testing in non-production environments or during planned maintenance windows. + +```bash +# Delete the primary pod to simulate failure +kubectl delete pod my-documentdb-1 -n documentdb + +# Watch failover (in another terminal) +kubectl get pods -n documentdb -w + +# Check pod status after failover +kubectl get pods -n documentdb -l documentdb.io/cluster=my-documentdb +``` + +### Test 3: Application Connectivity + +```bash +# Get the connection string from DocumentDB status +CONNECTION_STRING=$(kubectl get documentdb my-documentdb -n documentdb -o jsonpath='{.status.connectionString}') +echo "Connection string: $CONNECTION_STRING" + +# Test application can reconnect after failover +mongosh "$CONNECTION_STRING" --eval "print('Connection successful')" +``` + +## Troubleshooting + +### Pods Not Distributing Across Zones + +**Symptom**: Multiple DocumentDB pods scheduled on the same node or zone. + +**Cause**: Anti-affinity set to `preferred` and insufficient nodes/zones available. + +**Solution**: +1. Add more nodes to different zones +2. Or change to `required` anti-affinity (may prevent scheduling if constraints can't be met) + +```bash +# Check node zone labels +kubectl get nodes -L topology.kubernetes.io/zone +``` + +### Failover Taking Too Long + +**Symptom**: Failover takes longer than expected. + +**Possible Causes**: +- `stopDelay` set to high value +- Storage latency affecting shutdown +- Network issues delaying probe failures + +**Solution**: +```bash +# Check operator logs +kubectl logs -n documentdb-operator -l app.kubernetes.io/name=documentdb-operator --tail=100 + +# Check events +kubectl get events -n documentdb --sort-by='.lastTimestamp' | tail -20 +``` + +### Replica Not Catching Up + +**Symptom**: Replica shows increasing replication lag. + +**Possible Causes**: +- Network bandwidth limitation +- Storage I/O bottleneck on replica +- High write load on primary + +**Solution**: +```bash +# Check replica pod resources +kubectl top pod my-documentdb-2 -n documentdb + +# Check pod logs for replication issues +kubectl logs my-documentdb-2 -n documentdb --tail=50 +``` + diff --git a/docs/operator-public-documentation/preview/high-availability/overview.md b/docs/operator-public-documentation/preview/high-availability/overview.md new file mode 100644 index 00000000..1111890a --- /dev/null +++ b/docs/operator-public-documentation/preview/high-availability/overview.md @@ -0,0 +1,169 @@ +--- +title: High Availability Overview +description: Understanding high availability options for DocumentDB on Kubernetes - local HA, multi-region, and multi-cloud deployments. +tags: + - high-availability + - architecture + - disaster-recovery +search: + boost: 2 +--- + +# High Availability Overview + +High availability (HA) ensures your DocumentDB deployment remains accessible and operational despite component failures. This guide covers the HA options available and helps you choose the right approach for your requirements. + +## What is High Availability? + +High availability in DocumentDB means: + +- **Automatic failover**: When a primary instance fails, a replica is automatically promoted +- **Data durability**: Data is replicated across multiple instances before acknowledging writes +- **Minimal downtime**: Recovery happens automatically without manual intervention +- **Continuous operation**: Applications experience brief interruption rather than extended outages + +## Types of High Availability + +DocumentDB supports three levels of high availability, each providing different trade-offs between complexity, cost, and resilience: + +```mermaid +flowchart LR + subgraph Local["Local HA (Single Cluster)"] + direction TB + P1[Primary] --> R1[Replica 1] + P1 --> R2[Replica 2] + end + + subgraph MultiRegion["Multi-Region (Same Cloud)"] + direction TB + subgraph Region1[Region A] + P2[Primary Cluster] + end + subgraph Region2[Region B] + S2[Standby Cluster] + end + P2 -.->|Async Replication| S2 + end + + subgraph MultiCloud["Multi-Cloud"] + direction TB + subgraph Cloud1[Azure] + P3[Primary] + end + subgraph Cloud2[GCP] + S3a[Replica] + end + subgraph Cloud3[AWS] + S3b[Replica] + end + P3 -.->|Cross-Cloud| S3a + P3 -.->|Cross-Cloud| S3b + end + + Local ~~~ MultiRegion ~~~ MultiCloud +``` + +### Local High Availability + +Local HA runs multiple DocumentDB instances within a single Kubernetes cluster, distributed across availability zones. + +| Aspect | Details | +|--------|---------| +| **Scope** | Single Kubernetes cluster | +| **Instances** | 1-3 instances (primary + replicas) | +| **Failover** | Automatic, typically < 30 seconds | +| **Data Loss** | Zero (synchronous replication) | +| **Use Case** | Standard production deployments | + +**Best for:** Most production workloads requiring high availability without geographic distribution. + +[Configure Local HA →](local-ha.md) + +### Multi-Region Deployment + +Multi-region runs DocumentDB clusters across multiple regions within the same cloud provider, connected via the cloud's native networking. + +| Aspect | Details | +|--------|---------| +| **Scope** | Multiple regions, single cloud provider | +| **Networking** | Azure Fleet, VNet peering | +| **Failover** | Manual promotion required | +| **Data Loss** | Minimal (async replication lag) | +| **Use Case** | Disaster recovery, data locality | + +**Best for:** Disaster recovery requirements, regulatory compliance requiring data in specific regions, or reducing latency for geographically distributed users. + +### Multi-Cloud Deployment + +Multi-cloud runs DocumentDB across different cloud providers (Azure, AWS, GCP), connected via service mesh. + +| Aspect | Details | +|--------|---------| +| **Scope** | Multiple cloud providers | +| **Networking** | Istio service mesh | +| **Failover** | Manual promotion required | +| **Data Loss** | Minimal (async replication lag) | +| **Use Case** | Vendor independence, maximum resilience | + +**Best for:** Organizations requiring cloud vendor independence, maximum disaster resilience, or hybrid cloud strategies. + +## RTO and RPO Concepts + +When planning for high availability, understand these key metrics: + +### Recovery Time Objective (RTO) + +**RTO** is the maximum acceptable time your application can be unavailable after a failure. + +| HA Type | Typical RTO | +|---------|-------------| +| Local HA | < 30 seconds | +| Multi-Region | Minutes (manual failover) | +| Multi-Cloud | Minutes (manual failover) | + +### Recovery Point Objective (RPO) + +**RPO** is the maximum acceptable amount of data loss measured in time. + +| HA Type | Typical RPO | +|---------|-------------| +| Local HA | 0 (synchronous) | +| Multi-Region | Seconds (replication lag) | +| Multi-Cloud | Seconds (replication lag) | + +## Decision Tree + +Use this guide to select the appropriate HA strategy: + +```mermaid +flowchart TD + A[Start] --> B{Need geographic
distribution?} + B -->|No| C{Need zone-level
resilience?} + B -->|Yes| D{Need cloud vendor
independence?} + + C -->|Yes| E[Local HA
Multiple instances] + C -->|No| F[Single instance
Development only] + + D -->|Yes| G[Multi-Cloud
Istio mesh] + D -->|No| H[Multi-Region
Same cloud provider] + + E --> I[Configure instancesPerNode: 3] + F --> J[Configure instancesPerNode: 1] + G --> K[See Multi-Cloud Guide] + H --> L[See Multi-Region Guide] +``` + +## Trade-offs Summary + +| Factor | Local HA | Multi-Region | Multi-Cloud | +|--------|----------|--------------|-------------| +| **Complexity** | Low | Medium | High | +| **Cost** | $ | $$ | $$$ | +| **RTO** | Seconds | Minutes | Minutes | +| **RPO** | Zero | Replication lag | Replication lag | +| **Blast radius** | Zone outage | Region outage | Cloud outage | +| **Network latency** | Minimal | Regional | Variable | + +## Next Steps + +- [Configure Local HA](local-ha.md) - Set up high availability within a single cluster diff --git a/operator/cnpg-plugins/sidecar-injector/internal/config/config_test.go b/operator/cnpg-plugins/sidecar-injector/internal/config/config_test.go new file mode 100644 index 00000000..3e8f3205 --- /dev/null +++ b/operator/cnpg-plugins/sidecar-injector/internal/config/config_test.go @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package config + +import ( + "testing" + + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/common" + corev1 "k8s.io/api/core/v1" +) + +func TestParsePullPolicy(t *testing.T) { + tests := []struct { + name string + input string + expected corev1.PullPolicy + }{ + {"Always", "Always", corev1.PullAlways}, + {"Never", "Never", corev1.PullNever}, + {"IfNotPresent", "IfNotPresent", corev1.PullIfNotPresent}, + {"empty string returns empty", "", ""}, + {"invalid value returns empty", "invalid", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parsePullPolicy(tt.input) + if result != tt.expected { + t.Errorf("parsePullPolicy(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestApplyDefaults(t *testing.T) { + t.Run("sets default gateway image when empty", func(t *testing.T) { + config := &Configuration{} + config.applyDefaults() + expected := "ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.110.0" + if config.GatewayImage != expected { + t.Errorf("expected %q, got %q", expected, config.GatewayImage) + } + }) + + t.Run("sets IfNotPresent when pull policy is empty", func(t *testing.T) { + config := &Configuration{} + config.applyDefaults() + if config.GatewayImagePullPolicy != corev1.PullIfNotPresent { + t.Errorf("expected IfNotPresent, got %q", config.GatewayImagePullPolicy) + } + }) + + t.Run("preserves explicit pull policy", func(t *testing.T) { + config := &Configuration{GatewayImagePullPolicy: corev1.PullNever} + config.applyDefaults() + if config.GatewayImagePullPolicy != corev1.PullNever { + t.Errorf("expected Never, got %q", config.GatewayImagePullPolicy) + } + }) +} + +func TestFromParameters(t *testing.T) { + t.Run("pull policy from parameters", func(t *testing.T) { + helper := &common.Plugin{Parameters: map[string]string{ + "gatewayImagePullPolicy": "Never", + }} + config, errs := FromParameters(helper) + if len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } + if config.GatewayImagePullPolicy != corev1.PullNever { + t.Errorf("GatewayImagePullPolicy = %q, want Never", config.GatewayImagePullPolicy) + } + }) + + t.Run("defaults to IfNotPresent when not set", func(t *testing.T) { + helper := &common.Plugin{Parameters: map[string]string{}} + config, errs := FromParameters(helper) + if len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } + if config.GatewayImagePullPolicy != corev1.PullIfNotPresent { + t.Errorf("GatewayImagePullPolicy = %q, want IfNotPresent", config.GatewayImagePullPolicy) + } + }) +} + +func TestToParametersRoundTrip(t *testing.T) { + original := &Configuration{ + GatewayImage: "my-image:latest", + GatewayImagePullPolicy: corev1.PullNever, + } + original.applyDefaults() + + params, err := original.ToParameters() + if err != nil { + t.Fatalf("ToParameters() error: %v", err) + } + + helper := &common.Plugin{Parameters: params} + restored, errs := FromParameters(helper) + if len(errs) != 0 { + t.Fatalf("unexpected validation errors: %v", errs) + } + if restored.GatewayImagePullPolicy != original.GatewayImagePullPolicy { + t.Errorf("round-trip pull policy = %q, want %q", restored.GatewayImagePullPolicy, original.GatewayImagePullPolicy) + } + if restored.GatewayImage != original.GatewayImage { + t.Errorf("round-trip gateway image = %q, want %q", restored.GatewayImage, original.GatewayImage) + } +} diff --git a/plugins/wal-replica/.gitignore b/plugins/wal-replica/.gitignore new file mode 100644 index 00000000..57436766 --- /dev/null +++ b/plugins/wal-replica/.gitignore @@ -0,0 +1,7 @@ +bin/ +dist/ +.env +.vscode/ +.idea/ +.task/ +manifest.yaml diff --git a/plugins/wal-replica/Dockerfile b/plugins/wal-replica/Dockerfile new file mode 100644 index 00000000..69a5fddf --- /dev/null +++ b/plugins/wal-replica/Dockerfile @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Step 1: build image +FROM golang:1.24 AS builder + +# Cache the dependencies +WORKDIR /app +COPY go.mod go.sum /app/ +RUN go mod download + +# Compile the application +COPY . /app +RUN --mount=type=cache,target=/root/.cache/go-build ./scripts/build.sh + +# Step 2: build the image to be actually run +FROM golang:1-alpine +USER 10001:10001 +COPY --from=builder /app/bin/cnpg-i-wal-replica /app/bin/cnpg-i-wal-replica +ENTRYPOINT ["/app/bin/cnpg-i-wal-replica"] diff --git a/plugins/wal-replica/LICENSE b/plugins/wal-replica/LICENSE new file mode 100644 index 00000000..a5ed2a9a --- /dev/null +++ b/plugins/wal-replica/LICENSE @@ -0,0 +1,23 @@ +DocumentDB Kubernetes Operator + +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/plugins/wal-replica/README.md b/plugins/wal-replica/README.md new file mode 100644 index 00000000..a7cb7e69 --- /dev/null +++ b/plugins/wal-replica/README.md @@ -0,0 +1,142 @@ +# WAL Replica Pod Manager (CNPG-I Plugin) + +This plugin creates a standalone WAL receiver deployment alongside a [CloudNativePG](https://github.com/cloudnative-pg/cloudnative-pg/) cluster. It automatically provisions a Deployment named `-wal-receiver` that continuously streams Write-Ahead Log (WAL) files from the primary PostgreSQL cluster using `pg_receivewal`, with support for both synchronous and asynchronous replication modes. + +## Architecture + +The plugin uses the [CNPG-I](https://github.com/cloudnative-pg/cnpg-i) (CloudNativePG Interface) gRPC protocol to extend CloudNativePG without forking the operator. + +``` + + Kubernetes Cluster │ + │ + gRPC/TLS ┌────────────────────┐ │ ┌────────── + │ CloudNativePG│◄─────────────►│ WAL Replica Plugin │ │ + Operator │ (this plugin) │ ││ + └──────┬───────┘ └────────┬───────────┘ │ + │ │ │ + │ manages │ creates │ + ▼ ▼ │ + ┌──────────────┐ ┌────────────────────┐ │ + │ CNPG Cluster │ WAL stream │ WAL Receiver │ │ + │ (Primary PG) │◄─────────────│ Deployment + PVC │ │ + │ pg_receivewal (└──────────────┘)└────────── + +``` + +### CNPG-I Interfaces Implemented + +| Interface | Purpose | +|-----------|---------| +| **identity** | Declares plugin metadata and advertised capabilities | +| **operator** | Validates and mutates Cluster resources via webhooks | +| **reconciler** | Post-reconcile hook creates/updates the WAL receiver Deployment and PVC | + +> **Note:** The `SetStatusInCluster` capability is currently disabled due to an [oscillation bug](https://github.com/documentdb/documentdb-kubernetes-operator/pull/74) where the enabled field alternates on every reconciliation. The `MutateCluster` webhook is registered but not fully implemented upstream in CNPG as of v1.28; defaults are applied in the reconciler as a workaround. + +## Features + +- **Automated WAL Streaming**: Continuously receives and stores WAL files from the primary cluster +- **Persistent Storage**: Automatically creates and manages a PersistentVolumeClaim for WAL storage +- **TLS Security**: Uses cluster certificates for secure replication connections +- **Replication Slot Management**: Automatically creates and manages a dedicated replication slot (`wal_replica`) +- **Synchronous Replication Support**: Configurable synchronous/asynchronous replication modes +- **Health Probes**: Liveness and readiness probes on the WAL receiver container +- **Cluster Lifecycle Management**: Proper OwnerReferences ensure resources are cleaned up when the cluster is deleted +- **Deployment Updates**: Configuration changes are automatically patched onto the existing Deployment + +## Configuration + +Add the plugin to your Cluster specification: + +```yaml +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: my-cluster +spec: + instances: 3 + + plugins: + - name: cnpg-i-wal-replica.documentdb.io + parameters: + image: "ghcr.io/cloudnative-pg/postgresql:16" + replicationHost: "my-cluster-rw" + synchronous: "active" + walDirectory: "/var/lib/postgresql/wal" + walPVCSize: "20Gi" + verbose: "true" + compression: "0" + + replicationSlots: + synchronizeReplicas: + enabled: true + + storage: + size: 10Gi +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `image` | string | Cluster status image | Container image providing `pg_receivewal` binary | +| `replicationHost` | string | `-rw` | Primary host endpoint for WAL streaming | +| `synchronous` | string | `inactive` | Replication mode: `active` or `inactive` | +| `walDirectory` | string | `/var/lib/postgresql/wal` | Directory path for storing received WAL files | +| `walPVCSize` | string | `10Gi` | Size of the PersistentVolumeClaim for WAL storage | +| `verbose` | string | `true` | Enable verbose `pg_receivewal` output (`true` / `false`) | +| `compression` | string | `0` | Compression level for WAL files (0-9, 0=disabled) | + +## Deployment + +The plugin runs as an independent Deployment and is discovered by CNPG via a Kubernetes Service with the `cnpgi.io/pluginName` label: + +```bash +# Deploy using kustomize +kubectl apply -k kubernetes/ +``` + +See the `kubernetes/` directory for the full set of manifests (Deployment, Service, RBAC, TLS certificates). + +## Development + +### Build + +```bash +go build -o bin/cnpg-i-wal-replica main.go +``` + +### Test + +```bash +go test ./... +``` + +### Project Structure + +``` + cmd/plugin/ # Plugin command-line interface + internal/ + config/ # Configuration management and validation ├ + ├── identity/ # Plugin identity and capabilities + ├── k8sclient/ # Kubernetes client utilities + ├── operator/ # Operator hooks (validate, mutate, status) + └── reconciler/ # Reconciliation logic (Deployment + PVC management) + kubernetes/ # Kubernetes manifests + pkg/metadata/ # Plugin metadata constants + scripts/ # Build and deployment scripts +``` + +See [`doc/development.md`](doc/development.md) for detailed development guidelines. + +## Limitations and Known Issues + +- `MutateCluster` is not fully implemented upstream in CNPG v1.28; defaults are applied in the reconciler +- `SetStatusInCluster` is disabled due to status oscillation bug +- Fixed replication slot name (`wal_replica`) +- No built-in WAL retention/cleanup policies + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/plugins/wal-replica/cmd/plugin/doc.go b/plugins/wal-replica/cmd/plugin/doc.go new file mode 100644 index 00000000..b7790c20 --- /dev/null +++ b/plugins/wal-replica/cmd/plugin/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package plugin implements the command to start the plugin +package plugin diff --git a/plugins/wal-replica/cmd/plugin/plugin.go b/plugins/wal-replica/cmd/plugin/plugin.go new file mode 100644 index 00000000..b0dc4f97 --- /dev/null +++ b/plugins/wal-replica/cmd/plugin/plugin.go @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package plugin + +import ( + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/http" + "github.com/cloudnative-pg/cnpg-i/pkg/operator" + "github.com/cloudnative-pg/cnpg-i/pkg/reconciler" + "github.com/spf13/cobra" + "google.golang.org/grpc" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/documentdb/cnpg-i-wal-replica/internal/identity" + operatorImpl "github.com/documentdb/cnpg-i-wal-replica/internal/operator" + reconcilerImpl "github.com/documentdb/cnpg-i-wal-replica/internal/reconciler" +) + +// NewCmd creates the `plugin` command +func NewCmd() *cobra.Command { + cmd := http.CreateMainCmd(identity.Implementation{}, func(server *grpc.Server) error { + // Register the declared implementations + operator.RegisterOperatorServer(server, operatorImpl.Implementation{}) + reconciler.RegisterReconcilerHooksServer(server, reconcilerImpl.Implementation{}) + return nil + }) + + logger := zap.New(zap.UseDevMode(true)) + log.SetLogger(logger) + + cmd.Use = "receivewal" + + return cmd +} diff --git a/plugins/wal-replica/doc/development.md b/plugins/wal-replica/doc/development.md new file mode 100644 index 00000000..e8887f47 --- /dev/null +++ b/plugins/wal-replica/doc/development.md @@ -0,0 +1,442 @@ +# WAL Replica Plugin Development Guide + +This document provides comprehensive guidance for developers working with the WAL Replica plugin for CloudNativePG. It covers the CNPG-I framework capabilities used, implementation details, and development workflows. + +## Overview + +The WAL Replica plugin (`cnpg-i-wal-replica.documentdb.io`) is built using the CloudNativePG Interface (CNPG-I) framework, which provides a plugin architecture for extending CloudNativePG clusters with custom functionality. + +### Plugin Metadata + +```go +// From pkg/metadata/doc.go +const PluginName = "cnpg-i-wal-replica.documentdb.io" +Version: "0.1.0" +DisplayName: "WAL Replica Pod Manager" +License: "MIT" +Maturity: "alpha" +``` + +## CNPG-I Framework Concepts + +### Identity Interface + +The Identity interface is fundamental to all CNPG-I plugins and defines: + +- **Plugin Metadata**: Name, version, description, and licensing information +- **Capabilities**: Which CNPG-I services the plugin implements +- **Readiness**: Health check mechanism for the plugin + +The identity implementation is located in [`internal/identity/impl.go`](../internal/identity/impl.go). + +**Key Methods:** +- `GetPluginMetadata()`: Returns plugin information from `pkg/metadata` +- `GetPluginCapabilities()`: Declares supported services (Operator + Reconciler) +- `Probe()`: Always returns ready (stateless plugin) + +[CNPG-I Identity API Reference](https://github.com/cloudnative-pg/cnpg-i/blob/main/proto/identity.proto) + +### Implemented Capabilities + +This plugin implements two core CNPG-I capabilities: + +#### 1. Operator Interface + +Provides cluster-level validation and mutation capabilities through webhooks: + +```go +// From internal/operator/ +rpc ValidateClusterCreate(OperatorValidateClusterCreateRequest) returns (OperatorValidateClusterCreateResult) +rpc ValidateClusterChange(OperatorValidateClusterChangeRequest) returns (OperatorValidateClusterChangeResult) +rpc MutateCluster(OperatorMutateClusterRequest) returns (OperatorMutateClusterResult) +``` + +**Implementation Features:** +- **Parameter Validation**: Validates `synchronous`, `walPVCSize`, and other plugin parameters +- **Default Application**: Sets default values for image, replication host, WAL directory +- **Configuration Parsing**: Converts plugin parameters to typed configuration objects + +See [`internal/operator/validation.go`](../internal/operator/validation.go) and [`internal/operator/mutations.go`](../internal/operator/mutations.go). + +#### 2. Reconciler Hooks Interface + +Enables resource reconciliation and custom Kubernetes resource management: + +```go +// From internal/reconciler/ +rpc ReconcilerHook(ReconcilerHookRequest) returns (ReconcilerHookResponse) +``` + +**Core Functionality:** +- **WAL Receiver Deployment**: Creates and manages the `-wal-receiver` deployment +- **PVC Management**: Provisions persistent storage for WAL files +- **TLS Configuration**: Sets up certificate-based authentication +- **Resource Lifecycle**: Handles creation, updates, and cleanup with owner references + +See [`internal/reconciler/replica.go`](../internal/reconciler/replica.go) for the main implementation. + +[CNPG-I Operator API Reference](https://github.com/cloudnative-pg/cnpg-i/blob/main/proto/operator.proto) + +## Architecture Deep Dive + +### Configuration Management + +The plugin uses a layered configuration approach: + +```go +// From internal/config/config.go +type Configuration struct { + Image string // Container image for pg_receivewal + ReplicationHost string // Primary cluster endpoint + Synchronous SynchronousMode // active/inactive replication mode + WalDirectory string // WAL storage path + WalPVCSize string // Storage size for PVC +} +``` + +**Configuration Flow:** +1. Raw parameters from Cluster spec +2. Validation using `ValidateParams()` +3. Type conversion via `FromParameters()` +4. Default application with `ApplyDefaults()` + +### Resource Reconciliation + +The plugin creates and manages several Kubernetes resources: + +#### WAL Receiver Deployment + +```yaml +# Generated deployment structure +apiVersion: apps/v1 +kind: Deployment +metadata: + name: -wal-receiver + ownerReferences: [] +spec: + containers: + - name: wal-receiver + image: + command: ["/bin/bash", "-c"] + args: [""] + volumeMounts: + - name: wal-storage + mountPath: + - name: ca + mountPath: /var/lib/postgresql/rootcert + - name: tls + mountPath: /var/lib/postgresql/cert +``` + +#### pg_receivewal Command Construction + +The plugin builds sophisticated `pg_receivewal` commands: + +```bash +# Two-phase execution: +# 1. Create replication slot (if needed) +pg_receivewal --slot wal_replica --create-slot --if-not-exists --directory /path/to/wal --dbname "postgres://streaming_replica@host/postgres?sslmode=verify-full&..." + +# 2. Continuous WAL streaming +pg_receivewal --slot wal_replica --compress 0 --directory /path/to/wal --dbname "postgres://..." [--synchronous] [--verbose] +``` + +### Security Implementation + +**TLS Configuration:** +- Uses cluster-managed certificates from CloudNativePG +- Mounts CA certificate for SSL verification +- Client certificate authentication for `streaming_replica` user +- `sslmode=verify-full` for maximum security + +**Pod Security:** +- Runs as PostgreSQL user (`uid: 105, gid: 103`) +- Proper filesystem permissions (`fsGroup: 103`) +- Read-only certificate mounts + +## Development Environment Setup + +### Prerequisites + +```bash +# Required tools +go 1.24.1+ +docker or podman +kubectl +kind (for local testing) + +# Required Kubernetes components +cloudnative-pg operator +cnpg-i framework +cert-manager (for TLS) +``` + +### Local Development + +#### 1. Environment Setup + +```bash +# Clone repository +git clone https://github.com/documentdb/cnpg-i-wal-replica +cd cnpg-i-wal-replica + +# Install dependencies +go mod download + +# Verify build +go build -o bin/cnpg-i-wal-replica main.go +``` + +#### 2. Code Structure Navigation + +``` +├── cmd/plugin/ # CLI interface and gRPC server setup +│ ├── doc.go # Package documentation +│ └── plugin.go # Main command and service registration +├── internal/ +│ ├── config/ # Configuration management +│ │ ├── config.go # Configuration types and validation +│ │ └── doc.go # Package documentation +│ ├── identity/ # Plugin identity implementation +│ │ ├── impl.go # Identity service methods +│ │ └── doc.go # Package documentation +│ ├── k8sclient/ # Kubernetes client utilities +│ │ ├── k8sclient.go # Client initialization and management +│ │ └── doc.go # Package documentation +│ ├── operator/ # Operator interface implementation +│ │ ├── impl.go # Core operator methods +│ │ ├── mutations.go # Cluster mutation logic +│ │ ├── status.go # Status reporting +│ │ ├── validation.go# Parameter validation +│ │ └── doc.go # Package documentation +│ └── reconciler/ # Resource reconciliation +│ ├── impl.go # Reconciler hook implementation +│ ├── replica.go # WAL receiver resource management +│ └── doc.go # Package documentation +├── kubernetes/ # Deployment manifests +└── pkg/metadata/ # Plugin metadata constants +``` + +#### 3. Testing Locally + +```bash +# Build and test +./scripts/build.sh + +# Run with debugging +./scripts/run.sh + +# Test configuration parsing +go test ./internal/config/ + +# Test reconciliation logic +go test ./internal/reconciler/ +``` + +### Container Development + +#### Building Images + +```bash +# Local build +docker build -t wal-replica-plugin:dev . + +# Multi-arch build +docker buildx build --platform linux/amd64,linux/arm64 -t wal-replica-plugin:latest . +``` + +#### Kubernetes Testing + +```bash +# Load into kind cluster +kind load docker-image wal-replica-plugin:dev + +# Apply manifests +kubectl apply -f kubernetes/ + +# Deploy test cluster +kubectl apply -f doc/examples/cluster-example.yaml +``` + +## Extending the Plugin + +### Adding New Parameters + +1. **Define in Configuration**: +```go +// internal/config/config.go +const MyNewParam = "myNewParam" + +type Configuration struct { + // existing fields... + MyNewValue string +} +``` + +2. **Add Validation**: +```go +// internal/config/config.go +func ValidateParams(helper *common.Plugin) []*operator.ValidationError { + // existing validation... + + if raw, present := helper.Parameters[MyNewParam]; present { + // Add validation logic + } +} +``` + +3. **Update Reconciliation**: +```go +// internal/reconciler/replica.go +func CreateWalReplica(ctx context.Context, cluster *cnpgv1.Cluster) error { + // Use configuration.MyNewValue in resource creation +} +``` + +### Adding Resource Management + +```go +// Example: Adding a Service resource +func createWalReceiverService(ctx context.Context, cluster *cnpgv1.Cluster) error { + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-wal-receiver", cluster.Name), + Namespace: cluster.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: cluster.APIVersion, + Kind: cluster.Kind, + Name: cluster.Name, + UID: cluster.UID, + }}, + }, + Spec: corev1.ServiceSpec{ + Selector: map[string]string{"app": fmt.Sprintf("%s-wal-receiver", cluster.Name)}, + Ports: []corev1.ServicePort{{ + Port: 5432, + TargetPort: intstr.FromInt(5432), + }}, + }, + } + + return k8sclient.MustGet().Create(ctx, service) +} +``` + +## Debugging and Troubleshooting + +### Common Development Issues + +1. **gRPC Connection Problems**: +```bash +# Check plugin registration +kubectl logs -l app=cnpg-i-wal-replica + +# Verify TLS certificates +kubectl describe secret -ca-secret +``` + +2. **Resource Creation Failures**: +```bash +# Check reconciler logs +kubectl logs deployment/-wal-receiver + +# Verify owner references +kubectl get deployment -wal-receiver -o yaml +``` + +3. **Parameter Validation Errors**: +```bash +# Check cluster events +kubectl describe cluster + +# Review validation logs +kubectl logs -l app=cnpg-operator +``` + +### Testing Configurations + +```yaml +# Test with minimal parameters +spec: + plugins: + - name: cnpg-i-wal-replica.documentdb.io + # No parameters - should use all defaults + +# Test with full configuration +spec: + plugins: + - name: cnpg-i-wal-replica.documentdb.io + parameters: + image: "postgres:16" + replicationHost: "my-cluster-rw" + synchronous: "active" + walDirectory: "/custom/wal/path" + walPVCSize: "50Gi" +``` + +## CI/CD Integration + +### GitHub Actions Workflow + +The repository includes automated workflows for: + +- **Build Verification**: Compiles plugin for multiple architectures +- **Container Publishing**: Builds and pushes container images +- **Manifest Generation**: Creates deployment artifacts +- **Integration Testing**: Tests against live CloudNativePG clusters + +### Deployment Artifacts + +Generated manifests include: +- Plugin deployment with proper RBAC +- Certificate management for TLS +- Service definitions for plugin discovery +- Example cluster configurations + +## Contributing Guidelines + +### Code Standards + +- Follow Go conventions and `gofmt` formatting +- Add comprehensive unit tests for new functionality +- Document all public interfaces and complex logic +- Use structured logging with appropriate levels + +### Pull Request Process + +1. Fork and create feature branch +2. Implement changes with tests +3. Update documentation +4. Submit PR with detailed description +5. Address review feedback + +### Testing Requirements + +- Unit tests for all new configuration parameters +- Integration tests for resource reconciliation +- End-to-end testing with real CloudNativePG clusters +- Performance testing for WAL streaming scenarios + +## Future Development Roadmap + +### Planned Enhancements + +- **Enhanced Monitoring**: Prometheus metrics for WAL streaming +- **Multi-Zone Support**: Cross-region WAL archival capabilities +- **Backup Integration**: Coordination with CloudNativePG backup strategies +- **Resource Optimization**: Configurable resource requests/limits +- **Advanced Filtering**: WAL file retention and cleanup policies +- **Replica Support**: Extension to replica clusters for cascading replication + +### API Stability + +- Current API is alpha-level with potential breaking changes +- Plugin interface follows CNPG-I versioning conventions +- Configuration parameters may evolve based on user feedback + +## Resources + +- [CloudNativePG Documentation](https://cloudnative-pg.io/) +- [CNPG-I Framework](https://github.com/cloudnative-pg/cnpg-i) +- [PostgreSQL WAL Documentation](https://www.postgresql.org/docs/current/wal.html) +- [Plugin Examples Repository](https://github.com/cloudnative-pg/cnpg-i-examples) diff --git a/plugins/wal-replica/doc/examples/cluster-example-no-parameters.yaml b/plugins/wal-replica/doc/examples/cluster-example-no-parameters.yaml new file mode 100644 index 00000000..a926fe66 --- /dev/null +++ b/plugins/wal-replica/doc/examples/cluster-example-no-parameters.yaml @@ -0,0 +1,12 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: cluster-example +spec: + instances: 3 + + plugins: + - name: cnpg-i-wal-replica.documentdb.io + + storage: + size: 1Gi diff --git a/plugins/wal-replica/doc/examples/cluster-example-with-mistake.yaml b/plugins/wal-replica/doc/examples/cluster-example-with-mistake.yaml new file mode 100644 index 00000000..c46aa637 --- /dev/null +++ b/plugins/wal-replica/doc/examples/cluster-example-with-mistake.yaml @@ -0,0 +1,14 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: cluster-example +spec: + instances: 3 + + plugins: + - name: cnpg-i-wal-replica.documentdb.io + parameters: + replication_host: "cluster-example-0.cluster-example-svc.cnpg-system.svc.cluster.local" + synchronous: "badvalue" + storage: + size: 1Gi diff --git a/plugins/wal-replica/doc/examples/cluster-example.yaml b/plugins/wal-replica/doc/examples/cluster-example.yaml new file mode 100644 index 00000000..7961a303 --- /dev/null +++ b/plugins/wal-replica/doc/examples/cluster-example.yaml @@ -0,0 +1,18 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: cluster-example +spec: + instances: 1 + + plugins: + - name: cnpg-i-wal-replica.documentdb.io + + replicationSlots: + synchronizeReplicas: + enabled: true + + storage: + size: 1Gi + + logLevel: "debug" diff --git a/plugins/wal-replica/go.mod b/plugins/wal-replica/go.mod new file mode 100644 index 00000000..59d3d087 --- /dev/null +++ b/plugins/wal-replica/go.mod @@ -0,0 +1,109 @@ +module github.com/documentdb/cnpg-i-wal-replica + +go 1.24.1 + +require ( + github.com/cloudnative-pg/api v1.27.0 + github.com/cloudnative-pg/cnpg-i v0.3.0 + github.com/cloudnative-pg/cnpg-i-machinery v0.4.0 + github.com/cloudnative-pg/machinery v0.3.1 + github.com/spf13/cobra v1.10.1 + google.golang.org/grpc v1.75.0 + k8s.io/api v0.34.0 + k8s.io/apimachinery v0.34.0 + sigs.k8s.io/controller-runtime v0.22.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudnative-pg/barman-cloud v0.3.3 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.22.0 // indirect + github.com/go-openapi/jsonreference v0.21.1 // indirect + github.com/go-openapi/swag v0.24.1 // indirect + github.com/go-openapi/swag/cmdutils v0.24.0 // indirect + github.com/go-openapi/swag/conv v0.24.0 // indirect + github.com/go-openapi/swag/fileutils v0.24.0 // indirect + github.com/go-openapi/swag/jsonname v0.24.0 // indirect + github.com/go-openapi/swag/jsonutils v0.24.0 // indirect + github.com/go-openapi/swag/loading v0.24.0 // indirect + github.com/go-openapi/swag/mangling v0.24.0 // indirect + github.com/go-openapi/swag/netutils v0.24.0 // indirect + github.com/go-openapi/swag/stringutils v0.24.0 // indirect + github.com/go-openapi/swag/typeutils v0.24.0 // indirect + github.com/go-openapi/swag/yamlutils v0.24.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/hashicorp/hcl v1.0.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/klauspost/compress v1.18.0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.85.0 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.17.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/snorwin/jsonpatch v1.5.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spf13/viper v1.21.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/oauth2 v0.31.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/term v0.35.0 // indirect + golang.org/x/text v0.29.0 // indirect + golang.org/x/time v0.13.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver v0.34.0 // indirect + k8s.io/client-go v0.34.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250905212525-66792eed8611 // indirect + k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/plugins/wal-replica/go.sum b/plugins/wal-replica/go.sum new file mode 100644 index 00000000..72330df6 --- /dev/null +++ b/plugins/wal-replica/go.sum @@ -0,0 +1,410 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudnative-pg/api v1.25.1 h1:uNjKiB0MIspUeH9l651SnFDcuflr1crB3t6LjxUCafQ= +github.com/cloudnative-pg/api v1.25.1/go.mod h1:fwF5g4XkuNZqYXIeRR3AJvUfWlqWig+r2DXc5bEmw6U= +github.com/cloudnative-pg/api v1.27.0 h1:uSUkF9X/0UZu1Xn5qI33qHVmzZrDKuuyoiRlsOmSTv4= +github.com/cloudnative-pg/api v1.27.0/go.mod h1:IWyAmuirffHiw6iIGD1p18BmZNb13TK9Os/wkp8ltDg= +github.com/cloudnative-pg/barman-cloud v0.1.0 h1:e/z52CehMBIh1LjZqNBJnncWJbS+1JYvRMBR8Js6Uiw= +github.com/cloudnative-pg/barman-cloud v0.1.0/go.mod h1:rJUJO/f1yNckLZiVxHAyRmKY+4EPJkYRJsGbTZRJQSY= +github.com/cloudnative-pg/barman-cloud v0.3.3 h1:EEcjeV+IUivDpmyF/H/XGY1pGaKJ5LS5MYeB6wgGcak= +github.com/cloudnative-pg/barman-cloud v0.3.3/go.mod h1:5CM4MncAxAjnqxjDt0I5E/oVd7gsMLL0/o/wQ+vUSgs= +github.com/cloudnative-pg/cnpg-i v0.1.0 h1:QH2xTsrODMhEEc6B25GbOYe7ZIttDmSkYvXotfU5dfs= +github.com/cloudnative-pg/cnpg-i v0.1.0/go.mod h1:G28BhgUEHqrxEyyQeHz8BbpMVAsGuLhJm/tHUbDi8Sw= +github.com/cloudnative-pg/cnpg-i v0.3.0 h1:5ayNOG5x68lU70IVbHDZQrv5p+bErCJ0mqRmOpW2jjE= +github.com/cloudnative-pg/cnpg-i v0.3.0/go.mod h1:VOIWWXcJ1RyioK+elR2DGOa4cBA6K+6UQgx05aZmH+g= +github.com/cloudnative-pg/cnpg-i-machinery v0.2.0 h1:htNuKirdAOYrc7Hu5mLDoOES+nKSyPaXNDLgbV5dLSI= +github.com/cloudnative-pg/cnpg-i-machinery v0.2.0/go.mod h1:MHVxMMbLeCRnEM8PLWW4C2CsHqOeAU2OsrwWMKy3tPA= +github.com/cloudnative-pg/cnpg-i-machinery v0.4.0 h1:16wQt9qFFqvyxeg+9dPt8ic8dh3PRPq0jCGXVuZyjO4= +github.com/cloudnative-pg/cnpg-i-machinery v0.4.0/go.mod h1:4MCJzbCOsB7ianxlm8rqD+gDpkgVTHoTuglle/i72WA= +github.com/cloudnative-pg/machinery v0.1.0 h1:tjRmsqQmsO/OlaT0uFmkEtVqgr+SGPM88cKZOHYKLBo= +github.com/cloudnative-pg/machinery v0.1.0/go.mod h1:0V3vm44FaIsY+x4pm8ORry7xCC3AJiO+ebfPNxeP5Ck= +github.com/cloudnative-pg/machinery v0.3.1 h1:KtPA6EwELTUNisCMLiFYkK83GU9606rkGQhDJGPB8Yw= +github.com/cloudnative-pg/machinery v0.3.1/go.mod h1:jebuqKxZAbrRKDEEpVCIDMKW+FbWtB9Kf/hb2kMUu9o= +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/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/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/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.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-faker/faker/v4 v4.4.1 h1:LY1jDgjVkBZWIhATCt+gkl0x9i/7wC61gZx73GTFb+Q= +github.com/go-faker/faker/v4 v4.4.1/go.mod h1:HRLrjis+tYsbFtIHufEPTAIzcZiRu0rS9EYl2Ccwme4= +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= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= +github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= +github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8= +github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A= +github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= +github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= +github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik= +github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c= +github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak= +github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90= +github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= +github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= +github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= +github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= +github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= +github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= +github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= +github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= +github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= +github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM= +github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM= +github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w= +github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw= +github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI= +github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c= +github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +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-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +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/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0 h1:kQ0NI7W1B3HwiN5gAYtY+XFItDPbLBwYRxAqbFTyDes= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.2.0/go.mod h1:zrT2dxOAjNFPRGjTUe2Xmb4q4YdUwVvQFV6xiCSf+z0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/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.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.80.1 h1:DP+PUNVOc+Bkft8a4QunLzaZ0RspWuD3tBbcPHr2PeE= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.80.1/go.mod h1:6x4x0t9BP35g4XcjkHE9EB3RxhyfxpdpmZKd/Qyk8+M= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.85.0 h1:oY+F5FZFmCjCyzkHWPjVQpzvnvEB/0FP+iyzDUUlqFc= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.85.0/go.mod h1:VB7wtBmDT6W2RJHzsvPZlBId+EnmeQA0d33fFTXvraM= +github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA= +github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +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/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +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/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= +github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/snorwin/jsonpatch v1.5.0 h1:0m56YSt9cHiJOn8U+OcqdPGcDQZmhPM/zsG7Dv5QQP0= +github.com/snorwin/jsonpatch v1.5.0/go.mod h1:e0IDKlyFBLTFPqM0wa79dnMwjMs3XFvmKcrgCRpDqok= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/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/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.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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +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/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.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.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= +golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= +golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= +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/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= +golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= +golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= +golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls= +k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k= +k8s.io/api v0.34.0 h1:L+JtP2wDbEYPUeNGbeSa/5GwFtIA662EmT2YSLOkAVE= +k8s.io/api v0.34.0/go.mod h1:YzgkIzOOlhl9uwWCZNqpw6RJy9L2FK4dlJeayUoydug= +k8s.io/apiextensions-apiserver v0.32.2 h1:2YMk285jWMk2188V2AERy5yDwBYrjgWYggscghPCvV4= +k8s.io/apiextensions-apiserver v0.32.2/go.mod h1:GPwf8sph7YlJT3H6aKUWtd0E+oyShk/YHWQHf/OOgCA= +k8s.io/apiextensions-apiserver v0.34.0 h1:B3hiB32jV7BcyKcMU5fDaDxk882YrJ1KU+ZSkA9Qxoc= +k8s.io/apiextensions-apiserver v0.34.0/go.mod h1:hLI4GxE1BDBy9adJKxUxCEHBGZtGfIg98Q+JmTD7+g0= +k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= +k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/apimachinery v0.34.0 h1:eR1WO5fo0HyoQZt1wdISpFDffnWOvFLOOeJ7MgIv4z0= +k8s.io/apimachinery v0.34.0/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.32.2 h1:4dYCD4Nz+9RApM2b/3BtVvBHw54QjMFUl1OLcJG5yOA= +k8s.io/client-go v0.32.2/go.mod h1:fpZ4oJXclZ3r2nDOv+Ux3XcJutfrwjKTCHz2H3sww94= +k8s.io/client-go v0.34.0 h1:YoWv5r7bsBfb0Hs2jh8SOvFbKzzxyNo0nSb0zC19KZo= +k8s.io/client-go v0.34.0/go.mod h1:ozgMnEKXkRjeMvBZdV1AijMHLTh3pbACPvK7zFR+QQY= +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-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= +k8s.io/kube-openapi v0.0.0-20250905212525-66792eed8611 h1:o4oKOsvSymDkZRsMAPZU7bRdwL+lPOK5VS10Dr1D6eg= +k8s.io/kube-openapi v0.0.0-20250905212525-66792eed8611/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= +k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= +k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.20.3 h1:I6Ln8JfQjHH7JbtCD2HCYHoIzajoRxPNuvhvcDbZgkI= +sigs.k8s.io/controller-runtime v0.20.3/go.mod h1:xg2XB0K5ShQzAgsoujxuKN4LNXR2LfwwHsPj7Iaw+XY= +sigs.k8s.io/controller-runtime v0.22.1 h1:Ah1T7I+0A7ize291nJZdS1CabF/lB4E++WizgV24Eqg= +sigs.k8s.io/controller-runtime v0.22.1/go.mod h1:FwiwRjkRPbiN+zp2QRp7wlTCzbUXxZ/D4OzuQUDwBHY= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/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.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= +sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +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/plugins/wal-replica/internal/config/config.go b/plugins/wal-replica/internal/config/config.go new file mode 100644 index 00000000..e5305a67 --- /dev/null +++ b/plugins/wal-replica/internal/config/config.go @@ -0,0 +1,161 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package config + +import ( +"fmt" +"strconv" +"strings" + +cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" +"github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/common" +"github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/validation" +"github.com/cloudnative-pg/cnpg-i/pkg/operator" +"k8s.io/apimachinery/pkg/api/resource" +) + +// Plugin parameter keys +const ( +ImageParam = "image" // string: container image for the WAL receiver +ReplicationHostParam = "replicationHost" // string: primary host for WAL streaming +SynchronousParam = "synchronous" // enum: active, inactive +WalDirectoryParam = "walDirectory" // string: directory where WAL files are stored +WalPVCSizeParam = "walPVCSize" // string: PVC size (e.g., "10Gi") +VerboseParam = "verbose" // bool string: enable verbose pg_receivewal output +CompressionParam = "compression" // int string: compression level (0=disabled) +) + +// SynchronousMode represents the synchronous replication mode +type SynchronousMode string + +const ( +SynchronousUnset SynchronousMode = "" +SynchronousActive SynchronousMode = "active" +SynchronousInactive SynchronousMode = "inactive" +) + +const ( +defaultWalDir = "/var/lib/postgresql/wal" +defaultSynchronousMode = SynchronousInactive +defaultWalPVCSize = "10Gi" +defaultVerbose = true +defaultCompression = 0 +) + +// Configuration represents the plugin configuration parameters controlling the wal receiver pod +type Configuration struct { +Image string +ReplicationHost string +Synchronous SynchronousMode +WalDirectory string +WalPVCSize string +Verbose bool +Compression int +} + +// FromParameters builds a plugin configuration from the configuration parameters. +// Returns the configuration and any validation errors encountered during parsing. +func FromParameters(helper *common.Plugin) (*Configuration, []*operator.ValidationError) { +validationErrors := ValidateParams(helper) + +cfg := &Configuration{} +cfg.Image = helper.Parameters[ImageParam] +cfg.ReplicationHost = helper.Parameters[ReplicationHostParam] +cfg.Synchronous = SynchronousMode(strings.ToLower(helper.Parameters[SynchronousParam])) +cfg.WalDirectory = helper.Parameters[WalDirectoryParam] +cfg.WalPVCSize = helper.Parameters[WalPVCSizeParam] + +if raw, ok := helper.Parameters[VerboseParam]; ok && raw != "" { +cfg.Verbose = strings.EqualFold(raw, "true") +} else { +cfg.Verbose = defaultVerbose +} + +if raw, ok := helper.Parameters[CompressionParam]; ok && raw != "" { +if v, err := strconv.Atoi(raw); err == nil { +cfg.Compression = v +} +} + +return cfg, validationErrors +} + +// ValidateChanges validates the changes between the old configuration to the new configuration +func ValidateChanges(_ *Configuration, _ *Configuration, _ *common.Plugin) []*operator.ValidationError { +return nil +} + +// ToParameters serializes the configuration back to plugin parameters +func (c *Configuration) ToParameters() (map[string]string, error) { +params := map[string]string{ +ImageParam: c.Image, +ReplicationHostParam: c.ReplicationHost, +SynchronousParam: string(c.Synchronous), +WalDirectoryParam: c.WalDirectory, +WalPVCSizeParam: c.WalPVCSize, +VerboseParam: strconv.FormatBool(c.Verbose), +CompressionParam: strconv.Itoa(c.Compression), +} +return params, nil +} + +// ValidateParams ensures that the provided parameters are valid +func ValidateParams(helper *common.Plugin) []*operator.ValidationError { +validationErrors := make([]*operator.ValidationError, 0) + +if raw, present := helper.Parameters[SynchronousParam]; present && raw != "" { +switch SynchronousMode(strings.ToLower(raw)) { +case SynchronousActive, SynchronousInactive: +// valid +default: +validationErrors = append(validationErrors, validation.BuildErrorForParameter(helper, SynchronousParam, +fmt.Sprintf("invalid value '%s'; must be 'active' or 'inactive'", raw))) +} +} + +if raw, present := helper.Parameters[WalPVCSizeParam]; present && raw != "" { +if _, err := resource.ParseQuantity(raw); err != nil { +validationErrors = append(validationErrors, validation.BuildErrorForParameter(helper, WalPVCSizeParam, err.Error())) +} +} + +if raw, present := helper.Parameters[VerboseParam]; present && raw != "" { +if !strings.EqualFold(raw, "true") && !strings.EqualFold(raw, "false") { +validationErrors = append(validationErrors, validation.BuildErrorForParameter(helper, VerboseParam, +fmt.Sprintf("invalid value '%s'; must be 'true' or 'false'", raw))) +} +} + +if raw, present := helper.Parameters[CompressionParam]; present && raw != "" { +v, err := strconv.Atoi(raw) +if err != nil { +validationErrors = append(validationErrors, validation.BuildErrorForParameter(helper, CompressionParam, +fmt.Sprintf("invalid value '%s'; must be an integer", raw))) +} else if v < 0 || v > 9 { +validationErrors = append(validationErrors, validation.BuildErrorForParameter(helper, CompressionParam, +fmt.Sprintf("invalid value '%d'; must be between 0 and 9", v))) +} +} + +return validationErrors +} + +// ApplyDefaults fills the configuration with default values +func (c *Configuration) ApplyDefaults(cluster *cnpgv1.Cluster) { +if c.Image == "" { +c.Image = cluster.Status.Image +} +if c.ReplicationHost == "" { +c.ReplicationHost = cluster.Status.WriteService +} +if c.WalDirectory == "" { +c.WalDirectory = defaultWalDir +} +if c.Synchronous == SynchronousUnset { +c.Synchronous = defaultSynchronousMode +} +if c.WalPVCSize == "" { +c.WalPVCSize = defaultWalPVCSize +} +} diff --git a/plugins/wal-replica/internal/config/config_test.go b/plugins/wal-replica/internal/config/config_test.go new file mode 100644 index 00000000..6a9ff248 --- /dev/null +++ b/plugins/wal-replica/internal/config/config_test.go @@ -0,0 +1,226 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package config + +import ( +"testing" + +cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" +"github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/common" +metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +"k8s.io/utils/pointer" +) + +func newHelper(params map[string]string) *common.Plugin { +cluster := cnpgv1.Cluster{ +ObjectMeta: metav1.ObjectMeta{Name: "test-cluster", Namespace: "default"}, +Spec: cnpgv1.ClusterSpec{ +Plugins: []cnpgv1.PluginConfiguration{ +{ +Name: "cnpg-i-wal-replica.documentdb.io", +Enabled: pointer.Bool(true), +Parameters: params, +}, +}, +}, +} +return common.NewPlugin(cluster, "cnpg-i-wal-replica.documentdb.io") +} + +func TestFromParameters_Defaults(t *testing.T) { +helper := newHelper(map[string]string{}) +cfg, errs := FromParameters(helper) + +if len(errs) != 0 { +t.Fatalf("expected no validation errors, got %d", len(errs)) +} +if cfg.Verbose != true { +t.Errorf("expected verbose=true by default, got %v", cfg.Verbose) +} +if cfg.Compression != 0 { +t.Errorf("expected compression=0 by default, got %d", cfg.Compression) +} +if cfg.Image != "" { +t.Errorf("expected empty image before ApplyDefaults, got %q", cfg.Image) +} +} + +func TestFromParameters_WithValues(t *testing.T) { +helper := newHelper(map[string]string{ +ImageParam: "postgres:16", +ReplicationHostParam: "my-cluster-rw", +SynchronousParam: "Active", +WalDirectoryParam: "/custom/wal", +WalPVCSizeParam: "50Gi", +VerboseParam: "false", +CompressionParam: "5", +}) +cfg, errs := FromParameters(helper) + +if len(errs) != 0 { +t.Fatalf("expected no validation errors, got %d", len(errs)) +} +if cfg.Image != "postgres:16" { +t.Errorf("expected image postgres:16, got %q", cfg.Image) +} +if cfg.Synchronous != SynchronousActive { +t.Errorf("expected synchronous=active, got %q", cfg.Synchronous) +} +if cfg.WalPVCSize != "50Gi" { +t.Errorf("expected walPVCSize=50Gi, got %q", cfg.WalPVCSize) +} +if cfg.Verbose != false { +t.Errorf("expected verbose=false, got %v", cfg.Verbose) +} +if cfg.Compression != 5 { +t.Errorf("expected compression=5, got %d", cfg.Compression) +} +} + +func TestFromParameters_InvalidSynchronous(t *testing.T) { +helper := newHelper(map[string]string{ +SynchronousParam: "bogus", +}) +_, errs := FromParameters(helper) + +if len(errs) != 1 { +t.Fatalf("expected 1 validation error, got %d", len(errs)) +} +} + +func TestFromParameters_InvalidCompression(t *testing.T) { +tests := []struct { +name string +value string +}{ +{"not a number", "abc"}, +{"too high", "10"}, +{"negative", "-1"}, +} +for _, tt := range tests { +t.Run(tt.name, func(t *testing.T) { +helper := newHelper(map[string]string{ +CompressionParam: tt.value, +}) +_, errs := FromParameters(helper) +if len(errs) != 1 { +t.Fatalf("expected 1 validation error for %q, got %d", tt.value, len(errs)) +} +}) +} +} + +func TestFromParameters_InvalidVerbose(t *testing.T) { +helper := newHelper(map[string]string{ +VerboseParam: "yes", +}) +_, errs := FromParameters(helper) + +if len(errs) != 1 { +t.Fatalf("expected 1 validation error, got %d", len(errs)) +} +} + +func TestFromParameters_InvalidWalPVCSize(t *testing.T) { +helper := newHelper(map[string]string{ +WalPVCSizeParam: "not-a-quantity", +}) +_, errs := FromParameters(helper) + +if len(errs) != 1 { +t.Fatalf("expected 1 validation error, got %d", len(errs)) +} +} + +func TestApplyDefaults(t *testing.T) { +cfg := &Configuration{} +cluster := &cnpgv1.Cluster{ +Status: cnpgv1.ClusterStatus{ +Image: "postgres:16-default", +WriteService: "test-cluster-rw.default.svc", +}, +} + +cfg.ApplyDefaults(cluster) + +if cfg.Image != "postgres:16-default" { +t.Errorf("expected default image, got %q", cfg.Image) +} +if cfg.ReplicationHost != "test-cluster-rw.default.svc" { +t.Errorf("expected default replicationHost, got %q", cfg.ReplicationHost) +} +if cfg.WalDirectory != defaultWalDir { +t.Errorf("expected default walDirectory, got %q", cfg.WalDirectory) +} +if cfg.Synchronous != defaultSynchronousMode { +t.Errorf("expected default synchronous, got %q", cfg.Synchronous) +} +if cfg.WalPVCSize != defaultWalPVCSize { +t.Errorf("expected default walPVCSize, got %q", cfg.WalPVCSize) +} +} + +func TestApplyDefaults_NoOverwrite(t *testing.T) { +cfg := &Configuration{ +Image: "custom:latest", +ReplicationHost: "custom-host", +WalDirectory: "/custom", +Synchronous: SynchronousActive, +WalPVCSize: "100Gi", +} +cluster := &cnpgv1.Cluster{ +Status: cnpgv1.ClusterStatus{ +Image: "should-not-be-used", +WriteService: "should-not-be-used", +}, +} + +cfg.ApplyDefaults(cluster) + +if cfg.Image != "custom:latest" { +t.Errorf("expected custom image preserved, got %q", cfg.Image) +} +if cfg.ReplicationHost != "custom-host" { +t.Errorf("expected custom host preserved, got %q", cfg.ReplicationHost) +} +if cfg.WalPVCSize != "100Gi" { +t.Errorf("expected custom PVC size preserved, got %q", cfg.WalPVCSize) +} +} + +func TestToParameters_Roundtrip(t *testing.T) { +original := &Configuration{ +Image: "postgres:16", +ReplicationHost: "host-rw", +Synchronous: SynchronousActive, +WalDirectory: "/wal", +WalPVCSize: "20Gi", +Verbose: true, +Compression: 3, +} + +params, err := original.ToParameters() +if err != nil { +t.Fatalf("ToParameters failed: %v", err) +} + +helper := newHelper(params) +roundtripped, errs := FromParameters(helper) +if len(errs) != 0 { +t.Fatalf("unexpected validation errors: %v", errs) +} + +if roundtripped.Image != original.Image { +t.Errorf("image mismatch: %q vs %q", roundtripped.Image, original.Image) +} +if roundtripped.Synchronous != original.Synchronous { +t.Errorf("synchronous mismatch: %q vs %q", roundtripped.Synchronous, original.Synchronous) +} +if roundtripped.Verbose != original.Verbose { +t.Errorf("verbose mismatch: %v vs %v", roundtripped.Verbose, original.Verbose) +} +if roundtripped.Compression != original.Compression { +t.Errorf("compression mismatch: %d vs %d", roundtripped.Compression, original.Compression) +} +} diff --git a/plugins/wal-replica/internal/config/doc.go b/plugins/wal-replica/internal/config/doc.go new file mode 100644 index 00000000..fd2e2564 --- /dev/null +++ b/plugins/wal-replica/internal/config/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package config represents the plugin configuration +package config diff --git a/plugins/wal-replica/internal/identity/doc.go b/plugins/wal-replica/internal/identity/doc.go new file mode 100644 index 00000000..ec39c588 --- /dev/null +++ b/plugins/wal-replica/internal/identity/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package identity contains the implementation of the +// identity service +package identity diff --git a/plugins/wal-replica/internal/identity/impl.go b/plugins/wal-replica/internal/identity/impl.go new file mode 100644 index 00000000..c0bb0288 --- /dev/null +++ b/plugins/wal-replica/internal/identity/impl.go @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package identity + +import ( + "context" + + "github.com/cloudnative-pg/cnpg-i/pkg/identity" + + "github.com/documentdb/cnpg-i-wal-replica/pkg/metadata" +) + +// Implementation is the implementation of the identity service +type Implementation struct { + identity.IdentityServer +} + +// GetPluginMetadata implements the IdentityServer interface +func (Implementation) GetPluginMetadata( + context.Context, + *identity.GetPluginMetadataRequest, +) (*identity.GetPluginMetadataResponse, error) { + return &metadata.Data, nil +} + +// GetPluginCapabilities implements the IdentityServer interface +func (Implementation) GetPluginCapabilities( + context.Context, + *identity.GetPluginCapabilitiesRequest, +) (*identity.GetPluginCapabilitiesResponse, error) { + return &identity.GetPluginCapabilitiesResponse{ + Capabilities: []*identity.PluginCapability{ + { // TODO find out why this does nothing + Type: &identity.PluginCapability_Service_{ + Service: &identity.PluginCapability_Service{ + Type: identity.PluginCapability_Service_TYPE_OPERATOR_SERVICE, + }, + }, + }, + { + Type: &identity.PluginCapability_Service_{ + Service: &identity.PluginCapability_Service{ + Type: identity.PluginCapability_Service_TYPE_RECONCILER_HOOKS, + }, + }, + }, + }, + }, nil +} + +// Probe implements the IdentityServer interface +func (Implementation) Probe(context.Context, *identity.ProbeRequest) (*identity.ProbeResponse, error) { + return &identity.ProbeResponse{ + Ready: true, + }, nil +} diff --git a/plugins/wal-replica/internal/identity/impl_test.go b/plugins/wal-replica/internal/identity/impl_test.go new file mode 100644 index 00000000..3d156275 --- /dev/null +++ b/plugins/wal-replica/internal/identity/impl_test.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package identity + +import ( +"context" +"testing" + +"github.com/cloudnative-pg/cnpg-i/pkg/identity" +"github.com/documentdb/cnpg-i-wal-replica/pkg/metadata" +) + +func TestGetPluginMetadata(t *testing.T) { +impl := Implementation{} +resp, err := impl.GetPluginMetadata(context.Background(), &identity.GetPluginMetadataRequest{}) +if err != nil { +t.Fatalf("unexpected error: %v", err) +} +if resp.Name != metadata.PluginName { +t.Errorf("expected plugin name %q, got %q", metadata.PluginName, resp.Name) +} +} + +func TestGetPluginCapabilities(t *testing.T) { +impl := Implementation{} +resp, err := impl.GetPluginCapabilities(context.Background(), &identity.GetPluginCapabilitiesRequest{}) +if err != nil { +t.Fatalf("unexpected error: %v", err) +} + +if len(resp.Capabilities) == 0 { +t.Fatal("expected at least one capability") +} + +hasOperator := false +hasReconciler := false +for _, cap := range resp.Capabilities { +svc := cap.GetService() +if svc == nil { +continue +} +switch svc.Type { +case identity.PluginCapability_Service_TYPE_OPERATOR_SERVICE: +hasOperator = true +case identity.PluginCapability_Service_TYPE_RECONCILER_HOOKS: +hasReconciler = true +} +} + +if !hasOperator { +t.Error("expected TYPE_OPERATOR_SERVICE capability") +} +if !hasReconciler { +t.Error("expected TYPE_RECONCILER_HOOKS capability") +} +} + +func TestProbe(t *testing.T) { +impl := Implementation{} +resp, err := impl.Probe(context.Background(), &identity.ProbeRequest{}) +if err != nil { +t.Fatalf("unexpected error: %v", err) +} +if !resp.Ready { +t.Error("expected probe to report ready") +} +} diff --git a/plugins/wal-replica/internal/k8sclient/doc.go b/plugins/wal-replica/internal/k8sclient/doc.go new file mode 100644 index 00000000..8cf08d78 --- /dev/null +++ b/plugins/wal-replica/internal/k8sclient/doc.go @@ -0,0 +1,5 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package k8sclient ensure a Kubernetes client is available +package k8sclient diff --git a/plugins/wal-replica/internal/k8sclient/k8sclient.go b/plugins/wal-replica/internal/k8sclient/k8sclient.go new file mode 100644 index 00000000..a6975c55 --- /dev/null +++ b/plugins/wal-replica/internal/k8sclient/k8sclient.go @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package k8sclient + +import ( + "sync" + + apiv1 "github.com/cloudnative-pg/api/pkg/api/v1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +var ( + currentClient client.Client + mu sync.Mutex + scheme *runtime.Scheme +) + +// MustGet gets a Kubernetes client or panics is it cannot find it +func MustGet() client.Client { + cl, err := Get() + if err != nil { + panic(err) + } + + return cl +} + +// Get gets the Kubernetes client, creating it if needed. If an error during the +// creation of the Kubernetes client is raised, it will be returned +func Get() (client.Client, error) { + if currentClient != nil { + return currentClient, nil + } + + mu.Lock() + defer mu.Unlock() + + currentConfig, err := config.GetConfig() + if err != nil { + return nil, err + } + + newClient, err := client.New(currentConfig, client.Options{Scheme: scheme}) + if err != nil { + return nil, err + } + + currentClient = newClient + + return currentClient, nil +} + +func init() { + scheme = runtime.NewScheme() + _ = apiv1.AddToScheme(scheme) + _ = appsv1.AddToScheme(scheme) + _ = corev1.AddToScheme(scheme) +} diff --git a/plugins/wal-replica/internal/operator/doc.go b/plugins/wal-replica/internal/operator/doc.go new file mode 100644 index 00000000..15752638 --- /dev/null +++ b/plugins/wal-replica/internal/operator/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package operator contains the implementation of the +// operator service +package operator diff --git a/plugins/wal-replica/internal/operator/impl.go b/plugins/wal-replica/internal/operator/impl.go new file mode 100644 index 00000000..97894737 --- /dev/null +++ b/plugins/wal-replica/internal/operator/impl.go @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package operator + +import ( + "context" + + "github.com/cloudnative-pg/cnpg-i/pkg/operator" +) + +// Implementation is the implementation of the operator service +type Implementation struct { + operator.OperatorServer +} + +// GetCapabilities gets the capabilities of this operator hook +func (Implementation) GetCapabilities( + context.Context, + *operator.OperatorCapabilitiesRequest, +) (*operator.OperatorCapabilitiesResult, error) { + return &operator.OperatorCapabilitiesResult{ + Capabilities: []*operator.OperatorCapability{ + { + Type: &operator.OperatorCapability_Rpc{ + Rpc: &operator.OperatorCapability_RPC{ + Type: operator.OperatorCapability_RPC_TYPE_VALIDATE_CLUSTER_CREATE, + }, + }, + }, + { + Type: &operator.OperatorCapability_Rpc{ + Rpc: &operator.OperatorCapability_RPC{ + Type: operator.OperatorCapability_RPC_TYPE_VALIDATE_CLUSTER_CHANGE, + }, + }, + }, + // TYPE_SET_STATUS_IN_CLUSTER is disabled due to an oscillation bug + // where the enabled field alternates on every reconciliation cycle. + // Re-enable once the root cause is identified and fixed upstream. + { + Type: &operator.OperatorCapability_Rpc{ + Rpc: &operator.OperatorCapability_RPC{ + // NOTE: MutateCluster is not fully implemented on the CNPG operator + // side as of v1.28. Defaults are applied via ApplyDefaults in the + // reconciler as a workaround. + Type: operator.OperatorCapability_RPC_TYPE_MUTATE_CLUSTER, + }, + }, + }, + }, + }, nil +} diff --git a/plugins/wal-replica/internal/operator/mutations.go b/plugins/wal-replica/internal/operator/mutations.go new file mode 100644 index 00000000..8480be94 --- /dev/null +++ b/plugins/wal-replica/internal/operator/mutations.go @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package operator + +import ( + "context" + "fmt" + + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/common" + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/decoder" + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/object" + "github.com/cloudnative-pg/cnpg-i/pkg/operator" + "github.com/cloudnative-pg/machinery/pkg/log" + + "github.com/documentdb/cnpg-i-wal-replica/internal/config" + "github.com/documentdb/cnpg-i-wal-replica/pkg/metadata" +) + +// MutateCluster is called to mutate a cluster with the defaulting webhook. +// NOTE: MutateCluster is not fully implemented on the CNPG operator side as of CNPG 1.28. +// See: https://github.com/documentdb/documentdb-kubernetes-operator/pull/74#issuecomment-3518389125 +// Defaults are applied via ApplyDefaults in the reconciler as a workaround. +func (Implementation) MutateCluster( + ctx context.Context, + request *operator.OperatorMutateClusterRequest, +) (*operator.OperatorMutateClusterResult, error) { + logger := log.FromContext(ctx).WithName("MutateCluster") + logger.Warning("MutateCluster hook invoked") + cluster, err := decoder.DecodeClusterLenient(request.GetDefinition()) + if err != nil { + return nil, err + } + + helper := common.NewPlugin( + *cluster, + metadata.PluginName, + ) + + cfg, valErrs := config.FromParameters(helper) + if len(valErrs) > 0 { + return nil, fmt.Errorf("invalid plugin configuration: %s", valErrs[0].Message) + } + + mutatedCluster := cluster.DeepCopy() + if helper.PluginIndex >= 0 { + if mutatedCluster.Spec.Plugins[helper.PluginIndex].Parameters == nil { + mutatedCluster.Spec.Plugins[helper.PluginIndex].Parameters = make(map[string]string) + } + cfg.ApplyDefaults(cluster) + + mutatedCluster.Spec.Plugins[helper.PluginIndex].Parameters, err = cfg.ToParameters() + if err != nil { + return nil, err + } + } else { + logger.Info("Plugin not found in the cluster, skipping mutation", "plugin", metadata.PluginName) + } + + logger.Info("Mutated cluster", "cluster", mutatedCluster) + patch, err := object.CreatePatch(cluster, mutatedCluster) + if err != nil { + return nil, err + } + + return &operator.OperatorMutateClusterResult{ + JsonPatch: patch, + }, nil +} diff --git a/plugins/wal-replica/internal/operator/status.go b/plugins/wal-replica/internal/operator/status.go new file mode 100644 index 00000000..12a2467d --- /dev/null +++ b/plugins/wal-replica/internal/operator/status.go @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package operator + +import ( + "context" + "encoding/json" + "errors" + + cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/clusterstatus" + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/common" + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/decoder" + "github.com/cloudnative-pg/cnpg-i/pkg/operator" + "github.com/cloudnative-pg/machinery/pkg/log" + + "github.com/documentdb/cnpg-i-wal-replica/pkg/metadata" +) + +// Status represents the plugin status reported in the CNPG Cluster status. +type Status struct { + Enabled bool `json:"enabled"` +} + +// SetStatusInCluster reports plugin status in the Cluster resource. +// NOTE: This capability is currently disabled in GetCapabilities (impl.go) due to a +// known oscillation bug where the `enabled` field alternates on every reconciliation. +// See: https://github.com/documentdb/documentdb-kubernetes-operator/pull/74 +func (Implementation) SetStatusInCluster( + ctx context.Context, + req *operator.SetStatusInClusterRequest, +) (*operator.SetStatusInClusterResponse, error) { + logger := log.FromContext(ctx).WithName("SetStatusInCluster") + + cluster, err := decoder.DecodeClusterLenient(req.GetCluster()) + if err != nil { + return nil, err + } + + // TODO remove + logger.Debug("Debug worked?") + + plg := common.NewPlugin(*cluster, metadata.PluginName) + + // Find the status for our plugin + var pluginEntry *cnpgv1.PluginStatus + for idx, entry := range plg.Cluster.Status.PluginStatus { + if metadata.PluginName == entry.Name { + pluginEntry = &plg.Cluster.Status.PluginStatus[idx] + break + } + } + + if pluginEntry == nil { + err := errors.New("plugin entry not found in the cluster status") + logger.Error(err, "while fetching the plugin status", "plugin", metadata.PluginName) + return nil, errors.New("plugin entry not found") + } + + if plg.PluginIndex < 0 { + logger.Info("Plugin not being used, setting disabled status") + return clusterstatus.NewSetStatusInClusterResponseBuilder().JSONStatusResponse(Status{Enabled: false}) + } + + var status Status + if pluginEntry.Status != "" { + if err := json.Unmarshal([]byte(pluginEntry.Status), &status); err != nil { + logger.Error(err, "while unmarshalling plugin status", + "entry", pluginEntry) + return nil, err + } + } + + logger.Info("debug status snapshot", + "resourceVersion", cluster.ResourceVersion, + "pluginIndex", plg.PluginIndex, + "rawPluginStatus", pluginEntry.Status, + "decodedEnabled", status.Enabled, + ) + + if status.Enabled { + logger.Info("plugin is enabled, no action taken") + //return clusterstatus.NewSetStatusInClusterResponseBuilder().NoOpResponse(), nil + return clusterstatus.NewSetStatusInClusterResponseBuilder().JSONStatusResponse(Status{Enabled: true}) + } + + // TODO uncomment this line when the `enabled` field stops alternating constantly + logger.Info("setting enabled plugin status") + + return clusterstatus.NewSetStatusInClusterResponseBuilder().JSONStatusResponse(Status{Enabled: true}) +} diff --git a/plugins/wal-replica/internal/operator/validation.go b/plugins/wal-replica/internal/operator/validation.go new file mode 100644 index 00000000..82e0ac00 --- /dev/null +++ b/plugins/wal-replica/internal/operator/validation.go @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package operator + +import ( + "context" + + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/common" + "github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/decoder" + "github.com/cloudnative-pg/cnpg-i/pkg/operator" + "github.com/cloudnative-pg/machinery/pkg/log" + + "github.com/documentdb/cnpg-i-wal-replica/internal/config" + "github.com/documentdb/cnpg-i-wal-replica/pkg/metadata" +) + +// ValidateClusterCreate validates a cluster that is being created, +// Should validate all plugin parameters +func (Implementation) ValidateClusterCreate( + ctx context.Context, + request *operator.OperatorValidateClusterCreateRequest, +) (*operator.OperatorValidateClusterCreateResult, error) { + logger := log.FromContext(ctx).WithName("ValidateClusterCreate") + logger.Info("ValidateClusterCreate called") + cluster, err := decoder.DecodeClusterLenient(request.GetDefinition()) + if err != nil { + return nil, err + } + + result := &operator.OperatorValidateClusterCreateResult{} + + helper := common.NewPlugin( + *cluster, + metadata.PluginName, + ) + + result.ValidationErrors = config.ValidateParams(helper) + + return result, nil +} + +// ValidateClusterChange validates a cluster that is being changed +func (Implementation) ValidateClusterChange( + ctx context.Context, + request *operator.OperatorValidateClusterChangeRequest, +) (*operator.OperatorValidateClusterChangeResult, error) { + logger := log.FromContext(ctx).WithName("ValidateClusterChange") + logger.Info("ValidateClusterChange called") + result := &operator.OperatorValidateClusterChangeResult{} + + oldCluster, err := decoder.DecodeClusterLenient(request.GetOldCluster()) + if err != nil { + return nil, err + } + + newCluster, err := decoder.DecodeClusterLenient(request.GetNewCluster()) + if err != nil { + return nil, err + } + + oldClusterHelper := common.NewPlugin( + *oldCluster, + metadata.PluginName, + ) + + newClusterHelper := common.NewPlugin( + *newCluster, + metadata.PluginName, + ) + + newConfiguration, _ := config.FromParameters(newClusterHelper) + oldConfiguration, _ := config.FromParameters(oldClusterHelper) + result.ValidationErrors = config.ValidateChanges(oldConfiguration, newConfiguration, newClusterHelper) + + return result, nil +} diff --git a/plugins/wal-replica/internal/reconciler/doc.go b/plugins/wal-replica/internal/reconciler/doc.go new file mode 100644 index 00000000..43652219 --- /dev/null +++ b/plugins/wal-replica/internal/reconciler/doc.go @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package identity contains the implementation of the +// reconciler service +package reconciler diff --git a/plugins/wal-replica/internal/reconciler/impl.go b/plugins/wal-replica/internal/reconciler/impl.go new file mode 100644 index 00000000..e94cd99a --- /dev/null +++ b/plugins/wal-replica/internal/reconciler/impl.go @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package reconciler + +import ( + "context" + "encoding/json" + + cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" + "github.com/cloudnative-pg/cnpg-i/pkg/reconciler" + "github.com/cloudnative-pg/machinery/pkg/log" +) + +// Implementation is the implementation of the identity service +type Implementation struct { + reconciler.UnimplementedReconcilerHooksServer +} + +// GetCapabilities gets the capabilities of this operator lifecycle hook +func (Implementation) GetCapabilities( + context.Context, + *reconciler.ReconcilerHooksCapabilitiesRequest, +) (*reconciler.ReconcilerHooksCapabilitiesResult, error) { + return &reconciler.ReconcilerHooksCapabilitiesResult{ + ReconcilerCapabilities: []*reconciler.ReconcilerHooksCapability{ + { + Kind: reconciler.ReconcilerHooksCapability_KIND_CLUSTER, + }, + }, + }, nil +} + +func (Implementation) Post(ctx context.Context, req *reconciler.ReconcilerHooksRequest) (*reconciler.ReconcilerHooksResult, error) { + logger := log.FromContext(ctx).WithName("PostReconcilerHook") + cluster := &cnpgv1.Cluster{} + if err := json.Unmarshal(req.GetResourceDefinition(), cluster); err != nil { + logger.Error(err, "while decoding the cluster") + return nil, err + } + logger.Info("Post called for ", "cluster", cluster) + + if err := CreateWalReplica(ctx, cluster); err != nil { + logger.Error(err, "while creating the wal replica") + return nil, err + } + + return &reconciler.ReconcilerHooksResult{Behavior: reconciler.ReconcilerHooksResult_BEHAVIOR_CONTINUE}, nil +} + +func (Implementation) Pre(ctx context.Context, req *reconciler.ReconcilerHooksRequest) (*reconciler.ReconcilerHooksResult, error) { + // NOOP + return &reconciler.ReconcilerHooksResult{}, nil +} diff --git a/plugins/wal-replica/internal/reconciler/replica.go b/plugins/wal-replica/internal/reconciler/replica.go new file mode 100644 index 00000000..8c2e9987 --- /dev/null +++ b/plugins/wal-replica/internal/reconciler/replica.go @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package reconciler + +import ( +"context" +"fmt" +"strconv" +"strings" + +cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" +"github.com/cloudnative-pg/cnpg-i-machinery/pkg/pluginhelper/common" +"github.com/cloudnative-pg/machinery/pkg/log" +"github.com/documentdb/cnpg-i-wal-replica/internal/config" +"github.com/documentdb/cnpg-i-wal-replica/internal/k8sclient" +"github.com/documentdb/cnpg-i-wal-replica/pkg/metadata" +appsv1 "k8s.io/api/apps/v1" +corev1 "k8s.io/api/core/v1" +"k8s.io/apimachinery/pkg/api/errors" +"k8s.io/apimachinery/pkg/api/resource" +metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +"k8s.io/apimachinery/pkg/types" +"sigs.k8s.io/controller-runtime/pkg/client" +) + +// CreateWalReplica ensures a WAL receiver Deployment and PVC exist for the given cluster. +func CreateWalReplica( +ctx context.Context, +cluster *cnpgv1.Cluster, +) error { +logger := log.FromContext(ctx).WithName("CreateWalReplica") + +deploymentName := fmt.Sprintf("%s-wal-receiver", cluster.Name) +namespace := cluster.Namespace +k8sClient := k8sclient.MustGet() + +helper := common.NewPlugin(*cluster, metadata.PluginName) +configuration, valErrs := config.FromParameters(helper) +if len(valErrs) > 0 { +return fmt.Errorf("invalid plugin configuration: %s", valErrs[0].Message) +} + +// TODO: remove ApplyDefaults once MutateCluster is implemented upstream +configuration.ApplyDefaults(cluster) + +ownerRef := buildOwnerReference(cluster) +// Use a non-controller OwnerReference for the PVC so CNPG does not +// discover it as a managed PVC (which would require a cnpg.io/pvcRole label). +// Garbage collection still works; only the "controller" flag is cleared. +pvcOwnerRef := buildNonControllerOwnerReference(cluster) + +if err := ensurePVC(ctx, k8sClient, deploymentName, namespace, cluster.Name, configuration, pvcOwnerRef); err != nil { +return err +} + +desiredDep := buildDeployment(deploymentName, namespace, cluster, configuration, ownerRef) + +existing := &appsv1.Deployment{} +err := k8sClient.Get(ctx, types.NamespacedName{Name: deploymentName, Namespace: namespace}, existing) +if err != nil { +if !errors.IsNotFound(err) { +return err +} +if createErr := k8sClient.Create(ctx, desiredDep); createErr != nil { +logger.Error(createErr, "creating wal receiver deployment") +return createErr +} +logger.Info("created wal receiver deployment", "name", deploymentName) +return nil +} + +// Patch existing Deployment with desired spec +patch := client.MergeFrom(existing.DeepCopy()) +existing.Spec.Template.Spec.Containers = desiredDep.Spec.Template.Spec.Containers +existing.Spec.Template.Spec.Volumes = desiredDep.Spec.Template.Spec.Volumes +existing.Spec.Template.Spec.SecurityContext = desiredDep.Spec.Template.Spec.SecurityContext +if err := k8sClient.Patch(ctx, existing, patch); err != nil { +logger.Error(err, "patching wal receiver deployment") +return err +} +logger.Info("patched wal receiver deployment", "name", deploymentName) + +return nil +} + +func buildOwnerReference(cluster *cnpgv1.Cluster) metav1.OwnerReference { +return metav1.OwnerReference{ +APIVersion: cluster.APIVersion, +Kind: cluster.Kind, +Name: cluster.Name, +UID: cluster.UID, +Controller: boolPtr(true), +BlockOwnerDeletion: boolPtr(true), +} +} + +func buildNonControllerOwnerReference(cluster *cnpgv1.Cluster) metav1.OwnerReference { +return metav1.OwnerReference{ +APIVersion: cluster.APIVersion, +Kind: cluster.Kind, +Name: cluster.Name, +UID: cluster.UID, +Controller: boolPtr(false), +BlockOwnerDeletion: boolPtr(true), +} +} + +func ensurePVC( +ctx context.Context, +k8sClient client.Client, +name, namespace, clusterName string, +cfg *config.Configuration, +ownerRef metav1.OwnerReference, +) error { +logger := log.FromContext(ctx).WithName("ensurePVC") + +existing := &corev1.PersistentVolumeClaim{} +err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, existing) +if err == nil { +return nil +} +if !errors.IsNotFound(err) { +return err +} + +logger.Info("creating WAL replica PVC", "name", name, "size", cfg.WalPVCSize) +pvc := &corev1.PersistentVolumeClaim{ +ObjectMeta: metav1.ObjectMeta{ +Name: name, +Namespace: namespace, +Labels: map[string]string{ +"app": name, +"cnpg.io/cluster": clusterName, +}, +OwnerReferences: []metav1.OwnerReference{ownerRef}, +}, +Spec: corev1.PersistentVolumeClaimSpec{ +AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, +Resources: corev1.VolumeResourceRequirements{ +Requests: corev1.ResourceList{ +corev1.ResourceStorage: resource.MustParse(cfg.WalPVCSize), +}, +}, +}, +} + +return k8sClient.Create(ctx, pvc) +} + +func buildDeployment( +name, namespace string, +cluster *cnpgv1.Cluster, +cfg *config.Configuration, +ownerRef metav1.OwnerReference, +) *appsv1.Deployment { +walDir := cfg.WalDirectory + +args := []string{ +strings.Join([]string{ +buildWalReceiverCommand(cfg, walDir, true), +"&&", +buildWalReceiverCommand(cfg, walDir, false), +}, " "), +} + +container := corev1.Container{ +Name: "wal-receiver", +Image: cfg.Image, +Command: []string{"/bin/bash", "-c"}, +Args: args, +VolumeMounts: []corev1.VolumeMount{ +{Name: name, MountPath: walDir}, +{Name: "ca", MountPath: "/var/lib/postgresql/rootcert", ReadOnly: true}, +{Name: "tls", MountPath: "/var/lib/postgresql/cert", ReadOnly: true}, +}, +LivenessProbe: &corev1.Probe{ +ProbeHandler: corev1.ProbeHandler{ +Exec: &corev1.ExecAction{ +Command: []string{"sh", "-c", "cat /proc/*/cmdline 2>/dev/null | tr '\\0' ' ' | grep -q pg_receivewal"}, +}, +}, +InitialDelaySeconds: 10, +PeriodSeconds: 30, +FailureThreshold: 3, +}, +ReadinessProbe: &corev1.Probe{ +ProbeHandler: corev1.ProbeHandler{ +Exec: &corev1.ExecAction{ +Command: []string{"sh", "-c", "cat /proc/*/cmdline 2>/dev/null | tr '\\0' ' ' | grep -q pg_receivewal"}, +}, +}, +InitialDelaySeconds: 5, +PeriodSeconds: 10, +}, +} + +return &appsv1.Deployment{ +ObjectMeta: metav1.ObjectMeta{ +Name: name, +Namespace: namespace, +Labels: map[string]string{ +"app": name, +"cnpg.io/cluster": cluster.Name, +}, +OwnerReferences: []metav1.OwnerReference{ownerRef}, +}, +Spec: appsv1.DeploymentSpec{ +Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": name}}, +Template: corev1.PodTemplateSpec{ +ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": name}}, +Spec: corev1.PodSpec{ +Containers: []corev1.Container{container}, +Volumes: []corev1.Volume{ +{ +Name: name, +VolumeSource: corev1.VolumeSource{ +PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ +ClaimName: name, +}, +}, +}, +{ +Name: "ca", +VolumeSource: corev1.VolumeSource{ +Secret: &corev1.SecretVolumeSource{ +SecretName: cluster.Status.Certificates.ServerCASecret, +DefaultMode: int32Ptr(0600), +}, +}, +}, +{ +Name: "tls", +VolumeSource: corev1.VolumeSource{ +Secret: &corev1.SecretVolumeSource{ +SecretName: cluster.Status.Certificates.ReplicationTLSSecret, +DefaultMode: int32Ptr(0600), +}, +}, +}, +}, +// PostgreSQL user/group IDs matching the CNPG base image +SecurityContext: &corev1.PodSecurityContext{ +RunAsUser: int64Ptr(105), +RunAsGroup: int64Ptr(103), +FSGroup: int64Ptr(103), +}, +RestartPolicy: corev1.RestartPolicyAlways, +}, +}, +}, +} +} + +func buildWalReceiverCommand(cfg *config.Configuration, walDir string, createSlot bool) string { +connectionString := fmt.Sprintf( +"postgres://%s@%s/postgres?sslmode=verify-full&sslrootcert=%s&sslcert=%s&sslkey=%s", +"streaming_replica", +cfg.ReplicationHost, +"/var/lib/postgresql/rootcert/ca.crt", +"/var/lib/postgresql/cert/tls.crt", +"/var/lib/postgresql/cert/tls.key", +) + +parts := []string{ +"pg_receivewal", +"--slot", "wal_replica", +"--compress", strconv.Itoa(cfg.Compression), +"--directory", walDir, +"--dbname", fmt.Sprintf("%q", connectionString), +} + +if createSlot { +parts = append(parts, "--create-slot", "--if-not-exists") +} +if cfg.Verbose { +parts = append(parts, "--verbose") +} +if cfg.Synchronous == config.SynchronousActive { +parts = append(parts, "--synchronous") +} + +return strings.Join(parts, " ") +} + +func boolPtr(b bool) *bool { +return &b +} + +func int64Ptr(i int64) *int64 { +return &i +} + +func int32Ptr(i int32) *int32 { +return &i +} diff --git a/plugins/wal-replica/internal/reconciler/replica_test.go b/plugins/wal-replica/internal/reconciler/replica_test.go new file mode 100644 index 00000000..e0d54728 --- /dev/null +++ b/plugins/wal-replica/internal/reconciler/replica_test.go @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package reconciler + +import ( +"strings" +"testing" + +cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" +metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +"github.com/documentdb/cnpg-i-wal-replica/internal/config" +) + +func TestBuildWalReceiverCommand_CreateSlot(t *testing.T) { +cfg := &config.Configuration{ +ReplicationHost: "test-cluster-rw", +Verbose: true, +Compression: 0, +Synchronous: config.SynchronousInactive, +} + +cmd := buildWalReceiverCommand(cfg, "/var/lib/postgresql/wal", true) + +if !strings.Contains(cmd, "pg_receivewal") { +t.Error("expected pg_receivewal in command") +} +if !strings.Contains(cmd, "--create-slot") { +t.Error("expected --create-slot flag") +} +if !strings.Contains(cmd, "--if-not-exists") { +t.Error("expected --if-not-exists flag") +} +if !strings.Contains(cmd, "--verbose") { +t.Error("expected --verbose flag") +} +if strings.Contains(cmd, "--synchronous") { +t.Error("did not expect --synchronous flag when inactive") +} +} + +func TestBuildWalReceiverCommand_NoCreateSlot(t *testing.T) { +cfg := &config.Configuration{ +ReplicationHost: "test-cluster-rw", +Verbose: false, +Compression: 5, +Synchronous: config.SynchronousActive, +} + +cmd := buildWalReceiverCommand(cfg, "/wal", false) + +if strings.Contains(cmd, "--create-slot") { +t.Error("should not have --create-slot when createSlot=false") +} +if strings.Contains(cmd, "--verbose") { +t.Error("should not have --verbose when verbose=false") +} +if !strings.Contains(cmd, "--synchronous") { +t.Error("expected --synchronous flag when active") +} +if !strings.Contains(cmd, "--compress 5") { +t.Error("expected --compress 5") +} +} + +func TestBuildDeployment_Structure(t *testing.T) { +cluster := &cnpgv1.Cluster{ +ObjectMeta: metav1.ObjectMeta{ +Name: "test-cluster", +Namespace: "default", +UID: "test-uid-123", +}, +TypeMeta: metav1.TypeMeta{ +APIVersion: "postgresql.cnpg.io/v1", +Kind: "Cluster", +}, +Status: cnpgv1.ClusterStatus{ +Certificates: cnpgv1.CertificatesStatus{ + CertificatesConfiguration: cnpgv1.CertificatesConfiguration{ +ServerCASecret: "test-cluster-ca", +ReplicationTLSSecret: "test-cluster-replication", + }, +}, +}, +} + +cfg := &config.Configuration{ +Image: "postgres:16", +ReplicationHost: "test-cluster-rw", +WalDirectory: "/var/lib/postgresql/wal", +WalPVCSize: "10Gi", +Verbose: true, +Compression: 0, +Synchronous: config.SynchronousInactive, +} + +ownerRef := buildOwnerReference(cluster) +dep := buildDeployment("test-cluster-wal-receiver", "default", cluster, cfg, ownerRef) + +if dep.Name != "test-cluster-wal-receiver" { +t.Errorf("expected deployment name test-cluster-wal-receiver, got %q", dep.Name) +} +if dep.Namespace != "default" { +t.Errorf("expected namespace default, got %q", dep.Namespace) +} +if len(dep.OwnerReferences) != 1 { +t.Fatalf("expected 1 owner reference, got %d", len(dep.OwnerReferences)) +} +if dep.OwnerReferences[0].Controller == nil || !*dep.OwnerReferences[0].Controller { +t.Error("expected Controller=true on OwnerReference") +} +if dep.OwnerReferences[0].BlockOwnerDeletion == nil || !*dep.OwnerReferences[0].BlockOwnerDeletion { +t.Error("expected BlockOwnerDeletion=true on OwnerReference") +} + +// Check container +containers := dep.Spec.Template.Spec.Containers +if len(containers) != 1 { +t.Fatalf("expected 1 container, got %d", len(containers)) +} +container := containers[0] + +if container.Name != "wal-receiver" { +t.Errorf("expected container name wal-receiver, got %q", container.Name) +} +if container.Image != "postgres:16" { +t.Errorf("expected image postgres:16, got %q", container.Image) +} +if container.LivenessProbe == nil { +t.Error("expected liveness probe to be set") +} +if container.ReadinessProbe == nil { +t.Error("expected readiness probe to be set") +} + +// Check volumes +volumes := dep.Spec.Template.Spec.Volumes +if len(volumes) != 3 { +t.Errorf("expected 3 volumes, got %d", len(volumes)) +} + +// Check security context +sc := dep.Spec.Template.Spec.SecurityContext +if sc == nil { +t.Fatal("expected security context") +} +if *sc.RunAsUser != 105 { +t.Errorf("expected RunAsUser=105, got %d", *sc.RunAsUser) +} +} + +func TestBuildOwnerReference(t *testing.T) { +cluster := &cnpgv1.Cluster{ +ObjectMeta: metav1.ObjectMeta{ +Name: "test", +UID: "uid-abc", +}, +TypeMeta: metav1.TypeMeta{ +APIVersion: "postgresql.cnpg.io/v1", +Kind: "Cluster", +}, +} + +ref := buildOwnerReference(cluster) + +if ref.Name != "test" { +t.Errorf("expected name test, got %q", ref.Name) +} +if ref.Controller == nil || !*ref.Controller { +t.Error("expected Controller=true") +} +if ref.BlockOwnerDeletion == nil || !*ref.BlockOwnerDeletion { +t.Error("expected BlockOwnerDeletion=true") +} +} + +func TestBuildNonControllerOwnerReference(t *testing.T) { +cluster := &cnpgv1.Cluster{ +ObjectMeta: metav1.ObjectMeta{ +Name: "test", +UID: "uid-abc", +}, +TypeMeta: metav1.TypeMeta{ +APIVersion: "postgresql.cnpg.io/v1", +Kind: "Cluster", +}, +} + +ref := buildNonControllerOwnerReference(cluster) + +if ref.Name != "test" { +t.Errorf("expected name test, got %q", ref.Name) +} +if ref.Controller == nil || *ref.Controller { +t.Error("expected Controller=false") +} +if ref.BlockOwnerDeletion == nil || !*ref.BlockOwnerDeletion { +t.Error("expected BlockOwnerDeletion=true") +} +} diff --git a/plugins/wal-replica/kubernetes/certificate-issuer.yaml b/plugins/wal-replica/kubernetes/certificate-issuer.yaml new file mode 100644 index 00000000..8d3d6eee --- /dev/null +++ b/plugins/wal-replica/kubernetes/certificate-issuer.yaml @@ -0,0 +1,6 @@ +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: selfsigned-issuer +spec: + selfSigned: {} diff --git a/plugins/wal-replica/kubernetes/client-certificate.yaml b/plugins/wal-replica/kubernetes/client-certificate.yaml new file mode 100644 index 00000000..58c79b9a --- /dev/null +++ b/plugins/wal-replica/kubernetes/client-certificate.yaml @@ -0,0 +1,19 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: walreplica-client +spec: + secretName: walreplica-client-tls + + commonName: "walreplica-client" + duration: 2160h # 90d + renewBefore: 360h # 15d + + isCA: false + usages: + - client auth + + issuerRef: + name: selfsigned-issuer + kind: Issuer + group: cert-manager.io diff --git a/plugins/wal-replica/kubernetes/deployment.yaml b/plugins/wal-replica/kubernetes/deployment.yaml new file mode 100644 index 00000000..99469564 --- /dev/null +++ b/plugins/wal-replica/kubernetes/deployment.yaml @@ -0,0 +1,44 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: wal-replica + name: wal-replica +spec: + replicas: 1 + selector: + matchLabels: + app: wal-replica + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + app: wal-replica + spec: + serviceAccountName: wal-replica-manager + containers: + - image: cnpg-i-wal-replica:latest + name: cnpg-i-wal-replica + ports: + - containerPort: 9090 + protocol: TCP + args: + - receivewal + - --server-cert=/server/tls.crt + - --server-key=/server/tls.key + - --client-cert=/client/tls.crt + - --server-address=:9090 + volumeMounts: + - mountPath: /server + name: server + - mountPath: /client + name: client + resources: {} + volumes: + - name: server + secret: + secretName: walreplica-server-tls + - name: client + secret: + secretName: walreplica-client-tls diff --git a/plugins/wal-replica/kubernetes/kustomization.yaml b/plugins/wal-replica/kubernetes/kustomization.yaml new file mode 100644 index 00000000..b1f0287a --- /dev/null +++ b/plugins/wal-replica/kubernetes/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: cnpg-system +resources: +- certificate-issuer.yaml +- client-certificate.yaml +- deployment.yaml +- role-binding.yaml +- role.yaml +- server-certificate.yaml +- service-account.yaml +- service.yaml +images: +- name: cnpg-i-wal-replica + newName: ghcr.io/microsoft/documentdb-kubernetes-operator/wal-replica + newTag: latest diff --git a/plugins/wal-replica/kubernetes/role-binding.yaml b/plugins/wal-replica/kubernetes/role-binding.yaml new file mode 100644 index 00000000..0542860d --- /dev/null +++ b/plugins/wal-replica/kubernetes/role-binding.yaml @@ -0,0 +1,15 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/name: wal-replia-manager + app.kubernetes.io/managed-by: kustomize + name: wal-replica-manager-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: wal-replica-manager +subjects: +- kind: ServiceAccount + name: wal-replica-manager + namespace: cnpg-system diff --git a/plugins/wal-replica/kubernetes/role.yaml b/plugins/wal-replica/kubernetes/role.yaml new file mode 100644 index 00000000..aa3d37a0 --- /dev/null +++ b/plugins/wal-replica/kubernetes/role.yaml @@ -0,0 +1,26 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: wal-replica-manager +rules: # TODO trim this down to what's actually needed +- apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: [""] + resources: ["services", "pods", "endpoints", "leases", "serviceaccounts", "configmaps", "namespaces"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["metrics.k8s.io"] + resources: ["pods"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["rbac.authorization.k8s.io"] + resources: ["roles", "rolebindings"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: [""] + resources: ["secrets", "persistentvolumeclaims"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +- apiGroups: ["postgresql.cnpg.io"] + resources: ["clusters", "publications", "subscriptions"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] \ No newline at end of file diff --git a/plugins/wal-replica/kubernetes/server-certificate.yaml b/plugins/wal-replica/kubernetes/server-certificate.yaml new file mode 100644 index 00000000..1f573eab --- /dev/null +++ b/plugins/wal-replica/kubernetes/server-certificate.yaml @@ -0,0 +1,21 @@ +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: walreplica-server +spec: + secretName: walreplica-server-tls + commonName: "wal-replica" + dnsNames: + - wal-replica + + duration: 2160h # 90d + renewBefore: 360h # 15d + + isCA: false + usages: + - server auth + + issuerRef: + name: selfsigned-issuer + kind: Issuer + group: cert-manager.io diff --git a/plugins/wal-replica/kubernetes/service-account.yaml b/plugins/wal-replica/kubernetes/service-account.yaml new file mode 100644 index 00000000..cd9c6956 --- /dev/null +++ b/plugins/wal-replica/kubernetes/service-account.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: wal-replica-manager + namespace: cnpg-system + labels: + app.kubernetes.io/name: wal-replia-manager + app.kubernetes.io/managed-by: kustomize \ No newline at end of file diff --git a/plugins/wal-replica/kubernetes/service.yaml b/plugins/wal-replica/kubernetes/service.yaml new file mode 100644 index 00000000..b2217cb1 --- /dev/null +++ b/plugins/wal-replica/kubernetes/service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: wal-replica + cnpg.io/pluginName: cnpg-i-wal-replica.documentdb.io + annotations: + cnpg.io/pluginClientSecret: walreplica-client-tls + cnpg.io/pluginServerSecret: walreplica-server-tls + cnpg.io/pluginPort: "9090" + name: wal-replica +spec: + ports: + - port: 9090 + protocol: TCP + targetPort: 9090 + selector: + app: wal-replica diff --git a/plugins/wal-replica/main.go b/plugins/wal-replica/main.go new file mode 100644 index 00000000..0c7f251e --- /dev/null +++ b/plugins/wal-replica/main.go @@ -0,0 +1,33 @@ +// Package main is the entrypoint of the application +package main + +import ( + "fmt" + "os" + + "github.com/cloudnative-pg/machinery/pkg/log" + "github.com/spf13/cobra" + + "github.com/documentdb/cnpg-i-wal-replica/cmd/plugin" +) + +func main() { + logFlags := &log.Flags{} + rootCmd := &cobra.Command{ + Use: "cnpg-i-wal-replica", + Short: "WAL Replica", + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + logFlags.ConfigureLogging() + return nil + }, + } + + logFlags.AddFlags(rootCmd.PersistentFlags()) + + rootCmd.AddCommand(plugin.NewCmd()) + + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/plugins/wal-replica/pkg/metadata/doc.go b/plugins/wal-replica/pkg/metadata/doc.go new file mode 100644 index 00000000..1670754a --- /dev/null +++ b/plugins/wal-replica/pkg/metadata/doc.go @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Package metadata contains the metadata of this plugin +package metadata + +import "github.com/cloudnative-pg/cnpg-i/pkg/identity" + +// PluginName is the name of the plugin +const PluginName = "cnpg-i-wal-replica.documentdb.io" + +// Data is the metadata of this plugin +var Data = identity.GetPluginMetadataResponse{ + Name: PluginName, + Version: "0.1.0", + DisplayName: "WAL Replica Pod Manager", + ProjectUrl: "https://github.com/documentdb/cnpg-i-wal-replica", + RepositoryUrl: "https://github.com/documentdb/cnpg-i-wal-replica", + License: "MIT", + LicenseUrl: "https://github.com/documentdb/cnpg-i-wal-replica/LICENSE", + Maturity: "alpha", +} diff --git a/plugins/wal-replica/release-please-config.json b/plugins/wal-replica/release-please-config.json new file mode 100644 index 00000000..b3f9a1dc --- /dev/null +++ b/plugins/wal-replica/release-please-config.json @@ -0,0 +1,12 @@ +{ + "changelog-path": "CHANGELOG.md", + "release-type": "go", + "bump-minor-pre-major": false, + "bump-patch-for-minor-pre-major": false, + "draft": false, + "prerelease": false, + "packages": { + ".": {} + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} diff --git a/plugins/wal-replica/renovate.json5 b/plugins/wal-replica/renovate.json5 new file mode 100644 index 00000000..fa60c475 --- /dev/null +++ b/plugins/wal-replica/renovate.json5 @@ -0,0 +1,86 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + ":gitSignOff", + ":semanticCommitType(chore)", + ":labels(automated,no-issue)", + "customManagers:githubActionsVersions", + ":automergeMinor", + ":automergeDigest" + ], + "prConcurrentLimit": 5, + "ignorePaths": [ + "doc/**", + "CHANGELOG.md" + ], + "postUpdateOptions": [ + "gomodTidy" + ], + "semanticCommits": "enabled", + "commitBodyTable": true, + // Allow renovate to update the following types of dependencies in the Taskfile.yml: + // - digests for env variables ending in _SHA + // - versions for env variables ending in _VERSION + "customManagers": [ + { + "customType": "regex", + "fileMatch": [ + "(^Taskfile\\.yml$)" + ], + "matchStrings": [ + "# renovate: datasource=(?[a-z-.]+?) depName=(?[^\\s]+?)(?: (?:lookupName|packageName)=(?[^\\s]+?))?(?: versioning=(?[^\\s]+?))?(?: extractVersion=(?[^\\s]+?))?(?: currentValue=(?[^\\s]+?))?\\s+[A-Za-z0-9_]+?_SHA\\s*:\\s*[\"']?(?[a-f0-9]+?)[\"']?\\s", + "# renovate: datasource=(?[a-z-.]+?) depName=(?[^\\s]+?)(?: (?:lookupName|packageName)=(?[^\\s]+?))?(?: versioning=(?[^\\s]+?))?(?: extractVersion=(?[^\\s]+?))?\\s+[A-Za-z0-9_]+?_VERSION\\s*:\\s*[\"']?(?.+?)[\"']?\\s" + ] + } + ], + "packageRules": [ + { + "matchDatasources": [ + "go" + ], + "matchPackageNames": [ + // Avoid k8s dependencies from being grouped with other dependencies. We want to be careful + // with how we update them. + "!/k8s.io/" + ], + "matchUpdateTypes": [ + "minor", + "patch", + "digest" + ], + "groupName": "all non-major go dependencies" + }, + { + "matchDatasources": [ + "git-refs" + ], + "matchPackageNames": [ + "https://github.com/cloudnative-pg/daggerverse" + ], + "matchUpdateTypes": [ + "digest" + ], + "groupName": "all cloudnative-pg daggerverse dependencies" + }, + { + "matchDatasources": [ + "git-refs" + ], + "matchPackageNames": [ + "https://github.com/sagikazarmark/daggerverse" + ], + "matchUpdateTypes": [ + "digest" + ], + "groupName": "all sagikazarmark daggerverse dependencies" + }, + { + "matchUpdateTypes": [ + "minor", + "patch" + ], + "matchCurrentVersion": "!/^0/" + } + ] +} diff --git a/plugins/wal-replica/scripts/build.sh b/plugins/wal-replica/scripts/build.sh new file mode 100755 index 00000000..aeaa391e --- /dev/null +++ b/plugins/wal-replica/scripts/build.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +cd "$(dirname "$0")/.." || exit + +# Compile the plugin +CGO_ENABLED=0 go build -gcflags="all=-N -l" -o bin/cnpg-i-wal-replica main.go diff --git a/plugins/wal-replica/scripts/run.sh b/plugins/wal-replica/scripts/run.sh new file mode 100755 index 00000000..e9c7da2b --- /dev/null +++ b/plugins/wal-replica/scripts/run.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +set -eu + +cd "$(dirname "$0")/.." || exit + +if [ -f .env ]; then + source .env +fi + +# The following script builds the plugin image and uploads it to the +# current kind cluster +# WARNING: This will fail with recent releases of kind due to https://github.com/kubernetes-sigs/kind/issues/3853 +# See fix in CNPG https://github.com/cloudnative-pg/cloudnative-pg/pull/6770 +# current_context=$(kubectl config view --raw -o json | jq -r '."current-context"' | sed "s/kind-//") +# kind load docker-image --name=${current_context} cnpg-i-wal-replica:${VERSION:-latest} + +# Constants +registry_name=registry.dev + +load_image_registry() { + local image=$1 + + local image_reg_name=${registry_name}:5000/${image} + local image_local_name=${image_reg_name/${registry_name}/127.0.0.1} + docker tag "${image}" "${image_reg_name}" + docker tag "${image}" "${image_local_name}" + docker push -q "${image_local_name}" +} + +# Now we deploy the plugin inside the `cnpg-system` workspace +kubectl apply -k kubernetes/ + +# We load the image into the registry (which is a prerequisite) +load_image_registry cnpg-i-wal-replica:${VERSION:-latest} + +# Patch the deployment to use the provided image +kubectl patch deployments.apps -n cnpg-system wal-replica -p \ + "{\"spec\":{\"template\":{\"spec\":{\"containers\":[{\"name\":\"cnpg-i-wal-replica\",\"image\":\"${registry_name}:5000/cnpg-i-wal-replica:${VERSION:-latest}\"}]}}}}"