From 4711978f8e6067748748b1cba3412ec0464e446e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Wed, 22 Apr 2026 16:23:34 -0700 Subject: [PATCH 01/17] /{.github,charts}: add release and publish workflows --- .github/workflows/publish.yml | 73 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 35 ++++++++++++++ charts/doltdb-operator/values.yaml | 2 +- 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..4a31d92 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,73 @@ +name: Publish Helm Charts + +on: + release: + types: [published] + +permissions: + contents: read + id-token: write + +env: + REGISTRY: public.ecr.aws/dolthub + IMAGE_NAME: doltdb-operator-image + +jobs: + release: + name: Build and Publish + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Compute version + id: version + run: | + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 + with: + role-to-assume: ${{ secrets.ROLE_TO_ASSUME }} + aws-region: us-east-1 + + - name: Login to ECR Public + run: | + aws ecr-public get-login-password --region us-east-1 | \ + docker login --username AWS --password-stdin public.ecr.aws + aws ecr-public get-login-password --region us-east-1 | \ + helm registry login --username AWS --password-stdin public.ecr.aws + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 + + - name: Build and push container image + uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + + - name: Setup Helm + uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + + - name: Update chart image reference + run: | + VERSION="${{ steps.version.outputs.version }}" + sed -i "s|repository: .*|repository: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}|" charts/doltdb-operator/values.yaml + sed -i "s|tag: .*|tag: \"${VERSION}\"|" charts/doltdb-operator/values.yaml + + - name: Package and push Helm charts + run: | + VERSION="${{ steps.version.outputs.version }}" + + helm package charts/doltdb-operator-crds --version "$VERSION" --app-version "$VERSION" + helm package charts/doltdb-operator --version "$VERSION" --app-version "$VERSION" + + helm push "doltdb-operator-crds-${VERSION}.tgz" "oci://${{ env.REGISTRY }}" + helm push "doltdb-operator-${VERSION}.tgz" "oci://${{ env.REGISTRY }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..81acd62 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Release version (MAJOR.MINOR.PATCH)" + required: true + type: string + +permissions: + contents: write + +jobs: + release: + name: Create Release + runs-on: ubuntu-latest + steps: + - name: Validate version + run: | + if [[ ! "${{ inputs.version }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version must be in MAJOR.MINOR.PATCH format (e.g. 0.2.0)" + exit 1 + fi + + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Create release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "v${{ inputs.version }}" \ + --title "v${{ inputs.version }}" \ + --generate-notes diff --git a/charts/doltdb-operator/values.yaml b/charts/doltdb-operator/values.yaml index 343d892..aa6df8f 100644 --- a/charts/doltdb-operator/values.yaml +++ b/charts/doltdb-operator/values.yaml @@ -3,7 +3,7 @@ replicas: 1 image: - repository: ghcr.io/electronicarts/doltdb-operator + repository: public.ecr.aws/dolthub/doltdb-operator-image pullPolicy: IfNotPresent tag: "" # Defaults to chart appVersion From bf1ee9917eb1a38ee045b5bcec047adaad442841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Wed, 22 Apr 2026 16:28:38 -0700 Subject: [PATCH 02/17] /charts/doltdb-operator/README.md: update readme --- charts/doltdb-operator/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/doltdb-operator/README.md b/charts/doltdb-operator/README.md index e153773..fbdeab5 100644 --- a/charts/doltdb-operator/README.md +++ b/charts/doltdb-operator/README.md @@ -13,7 +13,7 @@ A Helm chart for Kubernetes deployment of Dolt operator | existingClusterRole | string | `""` | Name of an existing ClusterRole to use when installClusterRole is false. If not set, defaults to the fullname. | | fullnameOverride | string | `""` | | | image.pullPolicy | string | `"IfNotPresent"` | | -| image.repository | string | `"ghcr.io/electronicarts/doltdb-operator"` | | +| image.repository | string | `"public.ecr.aws/dolthub/doltdb-operator-image"` | | | image.tag | string | `""` | | | imagePullSecrets | list | `[]` | | | installClusterRole | bool | `true` | A boolean flag indicating whether ClusterRole and ClusterRoleBinding should be installed. Set to false for secondary installations. Default: true. | From 9addbcfcbec1f773d150b10b1d96df70a802be63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Wed, 22 Apr 2026 16:31:55 -0700 Subject: [PATCH 03/17] .github/workflows/{publish,release}.yml: pr feedback --- .github/workflows/publish.yml | 5 +++++ .github/workflows/release.yml | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4a31d92..5a5c8ee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: ${{ github.event.release.tag_name }} - name: Compute version id: version @@ -40,6 +42,9 @@ jobs: aws ecr-public get-login-password --region us-east-1 | \ helm registry login --username AWS --password-stdin public.ecr.aws + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81acd62..143ebb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,15 @@ jobs: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: main + + - name: Verify on main branch + run: | + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "::error::Releases must be created from the main branch" + exit 1 + fi - name: Create release env: From 1404a724999c5a10c815e411e077e8e5d1ec8bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Thu, 23 Apr 2026 12:15:10 -0700 Subject: [PATCH 04/17] /.github/workflows/release.yml: fix workflow to trigger publish one --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 143ebb7..4619c89 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,9 +35,16 @@ jobs: exit 1 fi + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v1.11.7 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Create release env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | gh release create "v${{ inputs.version }}" \ --title "v${{ inputs.version }}" \ From 01b32e1ed6f0c27e99b1a3b5467462e8e6fce798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Thu, 23 Apr 2026 12:40:44 -0700 Subject: [PATCH 05/17] /.github/workflows/publish.yml: fix build action sha --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5a5c8ee..6ae417a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -49,7 +49,7 @@ jobs: uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0 - name: Build and push container image - uses: docker/build-push-action@263435318d21b8e681c14492fe198e19c816612b # v6.18.0 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: linux/amd64,linux/arm64 From 6a8b83d0ff1025fb853f92ee98ae85154b44b23c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Thu, 23 Apr 2026 13:41:20 -0700 Subject: [PATCH 06/17] /.github/workflows: add latest tag --- .github/workflows/publish.yml | 12 +++++++++++- .github/workflows/release.yml | 8 +++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6ae417a..868fed0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,6 +28,7 @@ jobs: TAG="${{ github.event.release.tag_name }}" VERSION="${TAG#v}" echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "is_latest=${{ github.event.release.make_latest == 'true' }}" >> "$GITHUB_OUTPUT" - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 @@ -56,7 +57,7 @@ jobs: push: true tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} - ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + ${{ steps.version.outputs.is_latest == 'true' && format('{0}/{1}:latest', env.REGISTRY, env.IMAGE_NAME) || '' }} - name: Setup Helm uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 @@ -70,9 +71,18 @@ jobs: - name: Package and push Helm charts run: | VERSION="${{ steps.version.outputs.version }}" + IS_LATEST="${{ steps.version.outputs.is_latest }}" helm package charts/doltdb-operator-crds --version "$VERSION" --app-version "$VERSION" helm package charts/doltdb-operator --version "$VERSION" --app-version "$VERSION" helm push "doltdb-operator-crds-${VERSION}.tgz" "oci://${{ env.REGISTRY }}" helm push "doltdb-operator-${VERSION}.tgz" "oci://${{ env.REGISTRY }}" + + if [ "$IS_LATEST" = "true" ]; then + helm package charts/doltdb-operator-crds --version "latest" --app-version "$VERSION" + helm package charts/doltdb-operator --version "latest" --app-version "$VERSION" + + helm push "doltdb-operator-crds-latest.tgz" "oci://${{ env.REGISTRY }}" + helm push "doltdb-operator-latest.tgz" "oci://${{ env.REGISTRY }}" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4619c89..d35232f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,11 @@ on: description: "Release version (MAJOR.MINOR.PATCH)" required: true type: string + latest: + description: "Mark this release as latest" + required: false + type: boolean + default: true permissions: contents: write @@ -48,4 +53,5 @@ jobs: run: | gh release create "v${{ inputs.version }}" \ --title "v${{ inputs.version }}" \ - --generate-notes + --generate-notes \ + ${{ inputs.latest && '--latest' || '--latest=false' }} From b4d17c8494231a2762ee13d62c8bfe0aabada02e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Thu, 23 Apr 2026 13:42:33 -0700 Subject: [PATCH 07/17] /.github/workflows/publish.yml: make publish manually triggered --- .github/workflows/publish.yml | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 868fed0..972b5fe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,17 @@ name: Publish Helm Charts on: release: types: [published] + workflow_dispatch: + inputs: + version: + description: "Release version (MAJOR.MINOR.PATCH)" + required: true + type: string + latest: + description: "Tag artifacts as latest" + required: false + type: boolean + default: false permissions: contents: read @@ -20,15 +31,21 @@ jobs: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: - ref: ${{ github.event.release.tag_name }} + ref: ${{ github.event.release.tag_name || format('v{0}', inputs.version) }} - name: Compute version id: version run: | - TAG="${{ github.event.release.tag_name }}" - VERSION="${TAG#v}" + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ inputs.version }}" + IS_LATEST="${{ inputs.latest }}" + else + TAG="${{ github.event.release.tag_name }}" + VERSION="${TAG#v}" + IS_LATEST="${{ github.event.release.make_latest == 'true' }}" + fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - echo "is_latest=${{ github.event.release.make_latest == 'true' }}" >> "$GITHUB_OUTPUT" + echo "is_latest=${IS_LATEST}" >> "$GITHUB_OUTPUT" - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 From 1b1929fc8d85bfaeec6a75674c00c097a8c0c7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Thu, 23 Apr 2026 14:18:00 -0700 Subject: [PATCH 08/17] /.github/workflows: improve pipeline --- .github/workflows/promote.yml | 32 ++++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 13 +++++++++++-- .github/workflows/release.yml | 8 ++------ 3 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/promote.yml diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..790ca64 --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,32 @@ +name: Promote Release + +on: + workflow_call: + inputs: + tag_name: + description: "Release tag to promote (e.g. v0.1.0)" + required: true + type: string + workflow_dispatch: + inputs: + tag_name: + description: "Release tag to promote (e.g. v0.1.0)" + required: true + type: string + +permissions: + contents: write + +jobs: + promote: + name: Promote Pre-release + runs-on: ubuntu-latest + steps: + - name: Promote to release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release edit "${{ inputs.tag_name }}" \ + --repo "${{ github.repository }}" \ + --prerelease=false \ + --latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 972b5fe..b4eee8e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -16,7 +16,7 @@ on: default: false permissions: - contents: read + contents: write id-token: write env: @@ -42,7 +42,7 @@ jobs: else TAG="${{ github.event.release.tag_name }}" VERSION="${TAG#v}" - IS_LATEST="${{ github.event.release.make_latest == 'true' }}" + IS_LATEST="true" fi echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "is_latest=${IS_LATEST}" >> "$GITHUB_OUTPUT" @@ -103,3 +103,12 @@ jobs: helm push "doltdb-operator-crds-latest.tgz" "oci://${{ env.REGISTRY }}" helm push "doltdb-operator-latest.tgz" "oci://${{ env.REGISTRY }}" fi + + promote: + name: Promote Release + needs: release + if: github.event_name == 'release' + uses: ./.github/workflows/promote.yml + with: + tag_name: ${{ github.event.release.tag_name }} + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d35232f..3176dae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,11 +7,6 @@ on: description: "Release version (MAJOR.MINOR.PATCH)" required: true type: string - latest: - description: "Mark this release as latest" - required: false - type: boolean - default: true permissions: contents: write @@ -54,4 +49,5 @@ jobs: gh release create "v${{ inputs.version }}" \ --title "v${{ inputs.version }}" \ --generate-notes \ - ${{ inputs.latest && '--latest' || '--latest=false' }} + --prerelease \ + --latest=false From db0a7f42edff3a40450bb97b0f6763fac12d96f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Thu, 23 Apr 2026 14:21:37 -0700 Subject: [PATCH 09/17] /.github/workflows/publish.yml: make skippable --- .github/workflows/publish.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b4eee8e..62b66a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,6 +14,11 @@ on: required: false type: boolean default: false + promote: + description: "Promote the release after publishing" + required: false + type: boolean + default: false permissions: contents: write @@ -107,8 +112,8 @@ jobs: promote: name: Promote Release needs: release - if: github.event_name == 'release' + if: github.event_name == 'release' || inputs.promote uses: ./.github/workflows/promote.yml with: - tag_name: ${{ github.event.release.tag_name }} + tag_name: ${{ github.event.release.tag_name || format('v{0}', inputs.version) }} secrets: inherit From a2ad13c0240063994d1bd27d35bc1a0dd0a48c97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Thu, 23 Apr 2026 15:01:12 -0700 Subject: [PATCH 10/17] /.github/workflows/publish.yml: fix workflow --- .github/workflows/publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 62b66a3..d754209 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -84,6 +84,9 @@ jobs: - name: Setup Helm uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + - name: Setup crane + uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4 + - name: Update chart image reference run: | VERSION="${{ steps.version.outputs.version }}" @@ -102,11 +105,8 @@ jobs: helm push "doltdb-operator-${VERSION}.tgz" "oci://${{ env.REGISTRY }}" if [ "$IS_LATEST" = "true" ]; then - helm package charts/doltdb-operator-crds --version "latest" --app-version "$VERSION" - helm package charts/doltdb-operator --version "latest" --app-version "$VERSION" - - helm push "doltdb-operator-crds-latest.tgz" "oci://${{ env.REGISTRY }}" - helm push "doltdb-operator-latest.tgz" "oci://${{ env.REGISTRY }}" + crane tag "${{ env.REGISTRY }}/doltdb-operator-crds:${VERSION}" latest + crane tag "${{ env.REGISTRY }}/doltdb-operator:${VERSION}" latest fi promote: From bf4d973411d9e03f22cb5f5fde0f71e828348fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Thu, 23 Apr 2026 15:48:19 -0700 Subject: [PATCH 11/17] /.github/workflows/release.yml: include download info in release notes --- .github/workflows/release.yml | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3176dae..6fed91a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,9 +45,29 @@ jobs: - name: Create release env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ inputs.version }} run: | - gh release create "v${{ inputs.version }}" \ - --title "v${{ inputs.version }}" \ + INSTALL_NOTES="$(cat < Date: Fri, 24 Apr 2026 08:58:08 -0700 Subject: [PATCH 12/17] /{README.md,.github}: add container image --- .github/workflows/release.yml | 6 ++++++ README.md | 10 +++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fed91a..1e7ff91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,6 +57,12 @@ jobs: # Install operator helm install doltdb-operator oci://public.ecr.aws/dolthub/doltdb-operator --version ${VERSION} \`\`\` + + ## Container Image + + \`\`\`bash + docker pull public.ecr.aws/dolthub/doltdb-operator-image:${VERSION} + \`\`\` EOF )" diff --git a/README.md b/README.md index 87cf7af..7b77354 100644 --- a/README.md +++ b/README.md @@ -28,11 +28,11 @@ This operator takes care of all of that. Define your DoltDB cluster as a Kuberne ### Install with Helm ```sh -# Add the CRDs chart (install separately for lifecycle management) -helm install doltdb-operator-crds oci://ghcr.io/electronicarts/doltdb-operator-crds +# Install CRDs (separately for lifecycle management) +helm install doltdb-operator-crds oci://public.ecr.aws/dolthub/doltdb-operator-crds # Install the operator -helm install doltdb-operator oci://ghcr.io/electronicarts/doltdb-operator +helm install doltdb-operator oci://public.ecr.aws/dolthub/doltdb-operator ``` ### Deploy a DoltDB Cluster @@ -125,8 +125,8 @@ Key options: | Parameter | Description | Default | |-----------|-------------|---------| -| `image.repository` | Operator container image | — | -| `image.tag` | Operator image tag | — | +| `image.repository` | Operator container image | `public.ecr.aws/dolthub/doltdb-operator-image` | +| `image.tag` | Operator image tag | Chart appVersion | | `replicas` | Number of operator replicas | `1` | | `installCrds` | Install CRDs with the operator chart | `false` | | `installClusterRole` | Create ClusterRole and ClusterRoleBinding | `true` | From df48e3a94ac26fd5778e186ff08c2151e85a1915 Mon Sep 17 00:00:00 2001 From: Enrico Alvarenga Date: Tue, 2 Jun 2026 16:14:48 -0300 Subject: [PATCH 13/17] feat: bump default DOLTDB_ENGINE_VERSION to 1.88.1 Update the default engine version used for integration tests and the CRD sample manifest. Ref: CPEDEV-31401 --- Makefile | 2 +- config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 7015ac2..0ca48e3 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ IMG ?= controller:latest # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.31.0 -DOLTDB_ENGINE_VERSION ?= 1.83.6 +DOLTDB_ENGINE_VERSION ?= 1.88.1 export DOLTDB_ENGINE_VERSION # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) diff --git a/config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml b/config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml index c29cae3..0ffd5f7 100644 --- a/config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml +++ b/config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml @@ -4,7 +4,7 @@ metadata: name: dolt namespace: default spec: - engineVersion: 1.83.6 + engineVersion: 1.88.1 image: "dolthub/dolt" replicas: 2 podAnnotations: From 0f6d87a5ca75d18682a6905594e25f5f2aec787d Mon Sep 17 00:00:00 2001 From: Enrico Alvarenga Date: Mon, 5 Oct 2026 12:19:19 -0700 Subject: [PATCH 14/17] feat: bump default DOLTDB_ENGINE_VERSION to 2.4.1 (#13) Update the default engine version used by integration tests (Makefile and CI workflow), the CRD sample, and the README example. --- .github/workflows/integration-tests.yml | 4 ++-- Makefile | 2 +- README.md | 2 +- config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 23e2075..41ba6e8 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -8,7 +8,7 @@ on: doltdb_engine_version: description: "DoltDB engine version to test against" required: false - default: "1.83.6" + default: "2.4.1" issue_comment: types: [created] @@ -22,7 +22,7 @@ permissions: env: GO_VERSION_FILE: go.mod - DOLTDB_ENGINE_VERSION: ${{ github.event.inputs.doltdb_engine_version || '1.83.6' }} + DOLTDB_ENGINE_VERSION: ${{ github.event.inputs.doltdb_engine_version || '2.4.1' }} KIND_CLUSTER_NAME: dolt jobs: diff --git a/Makefile b/Makefile index 0ca48e3..e501053 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ IMG ?= controller:latest # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. ENVTEST_K8S_VERSION = 1.31.0 -DOLTDB_ENGINE_VERSION ?= 1.88.1 +DOLTDB_ENGINE_VERSION ?= 2.4.1 export DOLTDB_ENGINE_VERSION # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) diff --git a/README.md b/README.md index 7b77354..ebbb7d6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ kind: DoltDB metadata: name: my-dolt spec: - engineVersion: "1.57.2" + engineVersion: "2.4.1" image: dolthub/dolt replicas: 2 storage: diff --git a/config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml b/config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml index 0ffd5f7..a1bc775 100644 --- a/config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml +++ b/config/samples/k8s.dolthub.com_v1alpha_doltdb.yaml @@ -4,7 +4,7 @@ metadata: name: dolt namespace: default spec: - engineVersion: 1.88.1 + engineVersion: 2.4.1 image: "dolthub/dolt" replicas: 2 podAnnotations: From fd8a45a261dc8cb8aee5939e2d0e156172481869 Mon Sep 17 00:00:00 2001 From: Enrico Alvarenga Date: Thu, 15 Oct 2026 11:32:20 -0700 Subject: [PATCH 15/17] chore: upgrade kind test cluster to Kubernetes 1.36 (#14) * chore: upgrade kind test cluster to Kubernetes 1.36 * fix: download kubectl for the host platform --- .github/workflows/integration-tests.yml | 2 +- hack/manifests/kind/kind-ci.yaml | 2 +- hack/manifests/kind/kind.yaml | 4 ++-- make/deps.mk | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 41ba6e8..115bd9f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -55,7 +55,7 @@ jobs: - name: Install kind run: | - KIND_VERSION="v0.30.0" + KIND_VERSION="v0.33.0" curl -Lo ./kind "https://kind.sigs.k8s.io/dl/${KIND_VERSION}/kind-linux-amd64" chmod +x ./kind sudo mv ./kind /usr/local/bin/kind diff --git a/hack/manifests/kind/kind-ci.yaml b/hack/manifests/kind/kind-ci.yaml index bd147cf..52da0b7 100644 --- a/hack/manifests/kind/kind-ci.yaml +++ b/hack/manifests/kind/kind-ci.yaml @@ -3,4 +3,4 @@ apiVersion: kind.x-k8s.io/v1alpha4 name: kind-doltdb nodes: - role: control-plane - image: kindest/node:v1.34.0@sha256:7416a61b42b1662ca6ca89f02028ac133a309a2a30ba309614e8ec94d976dc5a \ No newline at end of file + image: kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed diff --git a/hack/manifests/kind/kind.yaml b/hack/manifests/kind/kind.yaml index e023ec9..7b0c299 100644 --- a/hack/manifests/kind/kind.yaml +++ b/hack/manifests/kind/kind.yaml @@ -3,6 +3,6 @@ apiVersion: kind.x-k8s.io/v1alpha4 name: kind-doltdb nodes: - role: control-plane - image: &image kindest/node:v1.34.0@sha256:7416a61b42b1662ca6ca89f02028ac133a309a2a30ba309614e8ec94d976dc5a + image: &image kindest/node:v1.36.4@sha256:099e049362a1526b2db71494e1947aae99bd16290d7c895f2b7ea312e3cbfaed - role: worker - image: *image \ No newline at end of file + image: *image diff --git a/make/deps.mk b/make/deps.mk index be2f374..3f2928a 100644 --- a/make/deps.mk +++ b/make/deps.mk @@ -21,7 +21,7 @@ CONTROLLER_TOOLS_VERSION ?= v0.16.1 ENVTEST_VERSION ?= release-0.19 GOLANGCI_LINT_VERSION ?= v1.63.4 GOLINES_VERSION ?= latest -KIND_VERSION ?= v0.30.0 +KIND_VERSION ?= v0.33.0 .PHONY: kustomize kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. @@ -56,7 +56,7 @@ ifeq (,$(shell which kubectl 2>/dev/null)) set -e ;\ mkdir -p $(dir $(KUBECTL)) ;\ OS=$(shell go env GOOS) && ARCH=$(shell go env GOARCH) && \ - curl -sSLo $(KUBECTL) https://dl.k8s.io/release/v1.31.0/bin/linux/$${ARCH}/kubectl ;\ + curl -sSLo $(KUBECTL) https://dl.k8s.io/release/v1.36.4/bin/$${OS}/$${ARCH}/kubectl ;\ chmod +x $(KUBECTL) ;\ } else From fd175443778032ff6de708a99557af0f357772a2 Mon Sep 17 00:00:00 2001 From: realnedsanders <5915044+realnedsanders@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:51:32 +0000 Subject: [PATCH 16/17] feat: add server.remotesAPI and server.behavior.eventScheduler to DoltDB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dolt's sql-server can serve its databases over the user-facing remotes API (the top-level `remotesapi` section of its configuration), so that clients clone, fetch and push with `dolt clone http://…/db`. The operator rendered only `cluster.remotesapi`, and only with replication enabled, and it patches its ConfigMap back on every reconcile, so the user-facing API could not be turned on for an operator-managed DoltDB. `spec.server.remotesAPI` is optional. When set, the operator renders `remotesapi.port` (default 50052, apart from the replication port 50051) and `remotesapi.read_only`, and adds the port as `http-remotesapi` to the container and to the primary, reader and internal Services. The one port carries the gRPC calls and the plain HTTP table-file transfers. `spec.server.behavior.eventScheduler` is optional, ON or OFF, and renders `behavior.event_scheduler`. When empty, Dolt's default (ON) applies, as before. Nothing changes for a DoltDB that sets neither field. --- api/v1alpha/doltdb_server_types.go | 33 +++++++++++++ api/v1alpha/zz_generated.deepcopy.go | 20 ++++++++ .../crd/k8s.dolthub.com_doltdbs.yaml | 26 ++++++++++ .../crd/k8s.dolthub.com_doltdbs.yaml | 26 ++++++++++ config/crd/bases/k8s.dolthub.com_doltdbs.yaml | 26 ++++++++++ pkg/builder/container_builder.go | 10 ++++ pkg/builder/container_builder_test.go | 33 +++++++++++++ pkg/builder/service_builder.go | 14 ++++++ pkg/builder/service_builder_test.go | 30 ++++++++++++ pkg/dolt/config.go | 9 ++++ pkg/dolt/generate.go | 8 ++++ pkg/dolt/generate_test.go | 47 +++++++++++++++++++ .../testdata/remotesapi_event_scheduler.yaml | 13 +++++ 13 files changed, 295 insertions(+) create mode 100644 pkg/dolt/testdata/remotesapi_event_scheduler.yaml diff --git a/api/v1alpha/doltdb_server_types.go b/api/v1alpha/doltdb_server_types.go index d6366b0..ffa9cea 100644 --- a/api/v1alpha/doltdb_server_types.go +++ b/api/v1alpha/doltdb_server_types.go @@ -26,6 +26,12 @@ type Server struct { // endpoint is started alongside the SQL server. // +optional MCPServer *MCPServer `json:"mcpServer,omitempty"` + // RemotesAPI enables Dolt's user-facing remotes API, which serves clone, fetch + // and push of the server's databases. When set, it is started alongside the SQL + // server and exposed on the Services. It is separate from Cluster.RemotesAPI, + // which carries replication traffic. + // +optional + RemotesAPI *ServerRemotesAPI `json:"remotesAPI,omitempty"` } type Profiler struct { @@ -39,6 +45,11 @@ type Behavior struct { // AutoGCBehavior defines the auto GC behavior for the DoltDB server. // +optional AutoGCBehavior AutoGCBehavior `json:"autoGCBehavior,omitempty"` + // EventScheduler sets the server's event scheduler. When empty, Dolt's default + // applies, which is ON. + // +kubebuilder:validation:Enum=ON;OFF + // +optional + EventScheduler string `json:"eventScheduler,omitempty"` } type AutoGCBehavior struct { @@ -98,6 +109,25 @@ type RemotesAPI struct { Port int32 `json:"port,omitempty"` } +// ServerRemotesAPI defines Dolt's user-facing remotes API. +type ServerRemotesAPI struct { + // Port defines the port for the remotes API. + // +kubebuilder:validation:Minimum=1024 + // +kubebuilder:validation:Maximum=65535 + // +kubebuilder:default:=50052 + // +optional + Port int32 `json:"port,omitempty"` + // ReadOnly makes the remotes API refuse pushes. + // +optional + ReadOnly bool `json:"readOnly,omitempty"` +} + +func (r *ServerRemotesAPI) ApplyDefaults() { + if r.Port == 0 { + r.Port = 50052 + } +} + // MCPServer defines the MCP server configuration for the DoltDB server. type MCPServer struct { // Port defines the HTTP port for the MCP server. @@ -140,6 +170,9 @@ func (s *Server) ApplyDefaults() { if s.MCPServer != nil { s.MCPServer.ApplyDefaults() } + if s.RemotesAPI != nil { + s.RemotesAPI.ApplyDefaults() + } } func (m *Metrics) ApplyDefaults() { diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go index 82374a6..872eea0 100644 --- a/api/v1alpha/zz_generated.deepcopy.go +++ b/api/v1alpha/zz_generated.deepcopy.go @@ -1229,6 +1229,11 @@ func (in *Server) DeepCopyInto(out *Server) { *out = new(MCPServer) (*in).DeepCopyInto(*out) } + if in.RemotesAPI != nil { + in, out := &in.RemotesAPI, &out.RemotesAPI + *out = new(ServerRemotesAPI) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Server. @@ -1241,6 +1246,21 @@ func (in *Server) DeepCopy() *Server { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerRemotesAPI) DeepCopyInto(out *ServerRemotesAPI) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerRemotesAPI. +func (in *ServerRemotesAPI) DeepCopy() *ServerRemotesAPI { + if in == nil { + return nil + } + out := new(ServerRemotesAPI) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Snapshot) DeepCopyInto(out *Snapshot) { *out = *in diff --git a/charts/doltdb-operator-crds/generated/crd/k8s.dolthub.com_doltdbs.yaml b/charts/doltdb-operator-crds/generated/crd/k8s.dolthub.com_doltdbs.yaml index 7c94baa..0c4f989 100644 --- a/charts/doltdb-operator-crds/generated/crd/k8s.dolthub.com_doltdbs.yaml +++ b/charts/doltdb-operator-crds/generated/crd/k8s.dolthub.com_doltdbs.yaml @@ -1863,6 +1863,14 @@ spec: description: Enable is a flag to enable the auto GC behavior. type: boolean type: object + eventScheduler: + description: |- + EventScheduler sets the server's event scheduler. When empty, Dolt's default + applies, which is ON. + enum: + - "ON" + - "OFF" + type: string type: object cluster: description: Cluster defines the cluster configuration for the @@ -1973,6 +1981,24 @@ spec: description: Enabled is a flag to enable the profiler. type: boolean type: object + remotesAPI: + description: |- + RemotesAPI enables Dolt's user-facing remotes API, which serves clone, fetch + and push of the server's databases. When set, it is started alongside the SQL + server and exposed on the Services. It is separate from Cluster.RemotesAPI, + which carries replication traffic. + properties: + port: + default: 50052 + description: Port defines the port for the remotes API. + format: int32 + maximum: 65535 + minimum: 1024 + type: integer + readOnly: + description: ReadOnly makes the remotes API refuse pushes. + type: boolean + type: object type: object serviceAccountAnnotations: additionalProperties: diff --git a/charts/doltdb-operator/generated/crd/k8s.dolthub.com_doltdbs.yaml b/charts/doltdb-operator/generated/crd/k8s.dolthub.com_doltdbs.yaml index 7c94baa..0c4f989 100644 --- a/charts/doltdb-operator/generated/crd/k8s.dolthub.com_doltdbs.yaml +++ b/charts/doltdb-operator/generated/crd/k8s.dolthub.com_doltdbs.yaml @@ -1863,6 +1863,14 @@ spec: description: Enable is a flag to enable the auto GC behavior. type: boolean type: object + eventScheduler: + description: |- + EventScheduler sets the server's event scheduler. When empty, Dolt's default + applies, which is ON. + enum: + - "ON" + - "OFF" + type: string type: object cluster: description: Cluster defines the cluster configuration for the @@ -1973,6 +1981,24 @@ spec: description: Enabled is a flag to enable the profiler. type: boolean type: object + remotesAPI: + description: |- + RemotesAPI enables Dolt's user-facing remotes API, which serves clone, fetch + and push of the server's databases. When set, it is started alongside the SQL + server and exposed on the Services. It is separate from Cluster.RemotesAPI, + which carries replication traffic. + properties: + port: + default: 50052 + description: Port defines the port for the remotes API. + format: int32 + maximum: 65535 + minimum: 1024 + type: integer + readOnly: + description: ReadOnly makes the remotes API refuse pushes. + type: boolean + type: object type: object serviceAccountAnnotations: additionalProperties: diff --git a/config/crd/bases/k8s.dolthub.com_doltdbs.yaml b/config/crd/bases/k8s.dolthub.com_doltdbs.yaml index 7c94baa..0c4f989 100644 --- a/config/crd/bases/k8s.dolthub.com_doltdbs.yaml +++ b/config/crd/bases/k8s.dolthub.com_doltdbs.yaml @@ -1863,6 +1863,14 @@ spec: description: Enable is a flag to enable the auto GC behavior. type: boolean type: object + eventScheduler: + description: |- + EventScheduler sets the server's event scheduler. When empty, Dolt's default + applies, which is ON. + enum: + - "ON" + - "OFF" + type: string type: object cluster: description: Cluster defines the cluster configuration for the @@ -1973,6 +1981,24 @@ spec: description: Enabled is a flag to enable the profiler. type: boolean type: object + remotesAPI: + description: |- + RemotesAPI enables Dolt's user-facing remotes API, which serves clone, fetch + and push of the server's databases. When set, it is started alongside the SQL + server and exposed on the Services. It is separate from Cluster.RemotesAPI, + which carries replication traffic. + properties: + port: + default: 50052 + description: Port defines the port for the remotes API. + format: int32 + maximum: 65535 + minimum: 1024 + type: integer + readOnly: + description: ReadOnly makes the remotes API refuse pushes. + type: boolean + type: object type: object serviceAccountAnnotations: additionalProperties: diff --git a/pkg/builder/container_builder.go b/pkg/builder/container_builder.go index 18e5055..9919384 100644 --- a/pkg/builder/container_builder.go +++ b/pkg/builder/container_builder.go @@ -21,6 +21,9 @@ const ( DoltMetricsPortName = "http-metrics" DoltProfilerPortName = "http-profiler" DoltMCPPortName = "http-mcp" + // DoltUserRemotesAPIPortName names the user-facing remotes API port, which serves + // gRPC and plain HTTP table-file transfers on one port. + DoltUserRemotesAPIPortName = "http-remotesapi" DoltDataVolume = "dolt-data" DoltConfigVolume = "dolt-config" @@ -145,6 +148,13 @@ func doltContainerPorts(doltdb *doltv1alpha.DoltDB) []corev1.ContainerPort { }) } + if doltdb.Spec.Server.RemotesAPI != nil { + ports = append(ports, corev1.ContainerPort{ + ContainerPort: doltdb.Spec.Server.RemotesAPI.Port, + Name: DoltUserRemotesAPIPortName, + }) + } + return ports } diff --git a/pkg/builder/container_builder_test.go b/pkg/builder/container_builder_test.go index 90631c2..de827d8 100644 --- a/pkg/builder/container_builder_test.go +++ b/pkg/builder/container_builder_test.go @@ -180,3 +180,36 @@ func TestDoltContainerPortsMCP(t *testing.T) { }) } } + +func TestDoltContainerPortsRemotesAPI(t *testing.T) { + without := &doltv1alpha.DoltDB{ + Spec: doltv1alpha.DoltDBSpec{ + Server: doltv1alpha.Server{ + Listener: doltv1alpha.Listener{Port: 3306}, + }, + }, + } + for _, p := range doltContainerPorts(without) { + if p.Name == DoltUserRemotesAPIPortName { + t.Errorf("unexpected remotes API container port %d", p.ContainerPort) + } + } + + with := &doltv1alpha.DoltDB{ + Spec: doltv1alpha.DoltDBSpec{ + Server: doltv1alpha.Server{ + Listener: doltv1alpha.Listener{Port: 3306}, + RemotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + }, + }, + } + var found *int32 + for _, p := range doltContainerPorts(with) { + if p.Name == DoltUserRemotesAPIPortName { + found = &p.ContainerPort + } + } + if found == nil || *found != 50052 { + t.Fatalf("expected remotes API container port 50052, got %v", found) + } +} diff --git a/pkg/builder/service_builder.go b/pkg/builder/service_builder.go index 3479424..b4feed2 100644 --- a/pkg/builder/service_builder.go +++ b/pkg/builder/service_builder.go @@ -26,6 +26,13 @@ func doltServicePorts(doltdb *doltv1alpha.DoltDB) []v1.ServicePort { }) } + if doltdb.Spec.Server.RemotesAPI != nil { + ports = append(ports, v1.ServicePort{ + Port: doltdb.Spec.Server.RemotesAPI.Port, + Name: DoltUserRemotesAPIPortName, + }) + } + return ports } @@ -49,6 +56,13 @@ func doltInternalServicePorts(doltdb *doltv1alpha.DoltDB) []v1.ServicePort { }) } + if doltdb.Spec.Server.RemotesAPI != nil { + ports = append(ports, v1.ServicePort{ + Port: doltdb.Spec.Server.RemotesAPI.Port, + Name: DoltUserRemotesAPIPortName, + }) + } + return ports } diff --git a/pkg/builder/service_builder_test.go b/pkg/builder/service_builder_test.go index 8d80d1e..137099b 100644 --- a/pkg/builder/service_builder_test.go +++ b/pkg/builder/service_builder_test.go @@ -578,3 +578,33 @@ func TestBuildDoltReaderService(t *testing.T) { }) } } + +func TestDoltServicePortsRemotesAPI(t *testing.T) { + doltdb := &doltv1alpha.DoltDB{ + ObjectMeta: metav1.ObjectMeta{Name: "doltdb-remotes", Namespace: "default"}, + Spec: doltv1alpha.DoltDBSpec{ + Server: doltv1alpha.Server{ + Listener: doltv1alpha.Listener{Port: 3306}, + Cluster: doltv1alpha.Cluster{RemotesAPI: doltv1alpha.RemotesAPI{Port: 50051}}, + RemotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + }, + }, + } + expected := []corev1.ServicePort{ + {Port: 3306, Name: DoltMySQLPortName}, + {Port: 50052, Name: DoltUserRemotesAPIPortName}, + } + ports := doltServicePorts(doltdb) + if len(ports) != len(expected) { + t.Fatalf("expected %d ports, got %d: %v", len(expected), len(ports), ports) + } + for i := range expected { + if ports[i].Port != expected[i].Port || ports[i].Name != expected[i].Name { + t.Errorf("port %d: expected %s %d, got %s %d", i, expected[i].Name, expected[i].Port, ports[i].Name, ports[i].Port) + } + } + internal := doltInternalServicePorts(doltdb) + if len(internal) != 3 || internal[2].Port != 50052 || internal[2].Name != DoltUserRemotesAPIPortName { + t.Errorf("internal service ports: expected the remotes API port last, got %v", internal) + } +} diff --git a/pkg/dolt/config.go b/pkg/dolt/config.go index e7c6718..cf3b6bd 100644 --- a/pkg/dolt/config.go +++ b/pkg/dolt/config.go @@ -23,6 +23,14 @@ type Config struct { Listener Listener `yaml:"listener"` Metrics Metrics `yaml:"metrics,omitempty"` MCPServer *MCPServer `yaml:"mcp_server,omitempty"` + // RemotesAPI is the user-facing remotes API, separate from Cluster.RemotesAPI. + RemotesAPI *UserRemotesAPI `yaml:"remotesapi,omitempty"` +} + +// UserRemotesAPI represents the top-level remotesapi section of the configuration. +type UserRemotesAPI struct { + Port int32 `yaml:"port"` + ReadOnly bool `yaml:"read_only,omitempty"` } // MCPServer represents the mcp_server section of the configuration. @@ -35,6 +43,7 @@ type MCPServer struct { type Behavior struct { AutoGCBehavior AutoGCBehavior `yaml:"auto_gc_behavior"` + EventScheduler string `yaml:"event_scheduler,omitempty"` } type AutoGCBehavior struct { diff --git a/pkg/dolt/generate.go b/pkg/dolt/generate.go index fc0bb0d..dfbe4c0 100644 --- a/pkg/dolt/generate.go +++ b/pkg/dolt/generate.go @@ -28,6 +28,7 @@ func GenerateConfigMapData(doltdb *doltv1alpha.DoltDB) (map[string]string, error AutoGCBehavior: AutoGCBehavior{ Enable: doltdb.Spec.Server.Behavior.AutoGCBehavior.Enable, }, + EventScheduler: doltdb.Spec.Server.Behavior.EventScheduler, }, LogLevel: doltdb.Spec.Server.LogLevel, Listener: Listener{ @@ -70,6 +71,13 @@ func GenerateConfigMapData(doltdb *doltv1alpha.DoltDB) (map[string]string, error } } + if doltdb.Spec.Server.RemotesAPI != nil { + config.RemotesAPI = &UserRemotesAPI{ + Port: doltdb.Spec.Server.RemotesAPI.Port, + ReadOnly: doltdb.Spec.Server.RemotesAPI.ReadOnly, + } + } + yamlData, err := yaml.Marshal(config) if err != nil { return nil, fmt.Errorf("error marshaling DoltDB config to YAML: %v", err) diff --git a/pkg/dolt/generate_test.go b/pkg/dolt/generate_test.go index fc13e30..588d286 100644 --- a/pkg/dolt/generate_test.go +++ b/pkg/dolt/generate_test.go @@ -238,6 +238,35 @@ func TestGenerateConfigMapData(t *testing.T) { expectedData: readTestData(t, "mcp_server_default_creds.yaml"), expectedError: false, }, + { + name: "with user-facing remotes API and event scheduler", + doltdb: &doltv1alpha.DoltDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, + Spec: doltv1alpha.DoltDBSpec{ + Replicas: 1, + Server: doltv1alpha.Server{ + Behavior: doltv1alpha.Behavior{ + EventScheduler: "OFF", + }, + Listener: doltv1alpha.Listener{ + Host: "0.0.0.0", + Port: 3306, + MaxConnections: 128, + }, + RemotesAPI: &doltv1alpha.ServerRemotesAPI{ + Port: 50052, + ReadOnly: true, + }, + LogLevel: "trace", + }, + }, + }, + expectedData: readTestData(t, "remotesapi_event_scheduler.yaml"), + expectedError: false, + }, } for _, tt := range tests { @@ -427,6 +456,24 @@ func TestConfigMCPServerOmitEmpty(t *testing.T) { } } +func TestConfigRemotesAPIAndEventSchedulerOmitEmpty(t *testing.T) { + config := Config{ + Behavior: Behavior{AutoGCBehavior: AutoGCBehavior{Enable: false}}, + LogLevel: "trace", + Listener: Listener{Host: "0.0.0.0", Port: 3306, MaxConnections: 128}, + } + yamlData, err := yaml.Marshal(config) + if err != nil { + t.Fatalf("unexpected error marshaling config: %v", err) + } + if strings.Contains(string(yamlData), "remotesapi:") { + t.Error("config with nil RemotesAPI should not contain 'remotesapi:' in YAML output") + } + if strings.Contains(string(yamlData), "event_scheduler:") { + t.Error("config without an event scheduler setting should not contain 'event_scheduler:' in YAML output") + } +} + func newTestDoltDBWithMCP(mcp *doltv1alpha.MCPServer) *doltv1alpha.DoltDB { return &doltv1alpha.DoltDB{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/dolt/testdata/remotesapi_event_scheduler.yaml b/pkg/dolt/testdata/remotesapi_event_scheduler.yaml new file mode 100644 index 0000000..efc8c0e --- /dev/null +++ b/pkg/dolt/testdata/remotesapi_event_scheduler.yaml @@ -0,0 +1,13 @@ +test-cluster-0.yaml: + behavior: + auto_gc_behavior: + enable: false + event_scheduler: "OFF" + log_level: trace + listener: + host: 0.0.0.0 + max_connections: 128 + port: 3306 + remotesapi: + port: 50052 + read_only: true From 016cb4bc726b59474d3e8642972d509416287021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?coffeegoddd=E2=98=95=EF=B8=8F=E2=9C=A8?= Date: Mon, 26 Oct 2026 15:08:24 -0700 Subject: [PATCH 17/17] fix: harden user-facing remotes API exposure and validation Follow-up to the user-facing remotes API support: - Expose the remotes API port only on the standalone and primary Services. Dolt does not refuse pushes to a standby through this API, so the reader and headless Services no longer advertise it. - Reject a remotesAPI.port that collides with the listener, cluster remotes API, MCP, metrics or profiler port with CEL rules, instead of letting the pod crash-loop on bind. - Document how the API authenticates: Basic credentials of a SQL user, CLONE_ADMIN for reads and SUPER for pushes, sent in plaintext. - Make the container and Service port tests table-driven, unit-test the CEL rules against the generated CRD schema, and add integration tests for clone over the remotes API, refusal of unauthenticated clones, the event scheduler and admission of colliding ports. --- api/v1alpha/doltdb_server_types.go | 19 +- api/v1alpha/doltdb_server_types_test.go | 157 +++++++++++ .../crd/k8s.dolthub.com_doltdbs.yaml | 30 +- .../crd/k8s.dolthub.com_doltdbs.yaml | 30 +- config/crd/bases/k8s.dolthub.com_doltdbs.yaml | 30 +- go.mod | 6 +- go.sum | 14 + .../doltdb_controller_remotesapi_test.go | 263 ++++++++++++++++++ internal/controller/doltdb_controller_test.go | 13 +- internal/controller/utils_test.go | 1 + pkg/builder/container_builder_test.go | 74 +++-- pkg/builder/service_builder.go | 21 +- pkg/builder/service_builder_test.go | 116 ++++++-- 13 files changed, 697 insertions(+), 77 deletions(-) create mode 100644 api/v1alpha/doltdb_server_types_test.go create mode 100644 internal/controller/doltdb_controller_remotesapi_test.go diff --git a/api/v1alpha/doltdb_server_types.go b/api/v1alpha/doltdb_server_types.go index ffa9cea..f5a80ad 100644 --- a/api/v1alpha/doltdb_server_types.go +++ b/api/v1alpha/doltdb_server_types.go @@ -2,6 +2,13 @@ package v1alpha +// The user-facing remotes API port must not collide with any other port the server listens on. +// Defaults are spelled out because an omitted parent object is not defaulted. +// +kubebuilder:validation:XValidation:rule="!has(self.remotesAPI) || self.remotesAPI.port != (has(self.listener) && has(self.listener.port) ? self.listener.port : 3306)",message="remotesAPI.port must differ from listener.port" +// +kubebuilder:validation:XValidation:rule="!has(self.remotesAPI) || self.remotesAPI.port != (has(self.cluster) && has(self.cluster.remotesAPI) && has(self.cluster.remotesAPI.port) ? self.cluster.remotesAPI.port : 50051)",message="remotesAPI.port must differ from cluster.remotesAPI.port" +// +kubebuilder:validation:XValidation:rule="!has(self.remotesAPI) || !has(self.mcpServer) || self.remotesAPI.port != (has(self.mcpServer.port) ? self.mcpServer.port : 7007)",message="remotesAPI.port must differ from mcpServer.port" +// +kubebuilder:validation:XValidation:rule="!has(self.remotesAPI) || !has(self.metrics) || !has(self.metrics.enabled) || !self.metrics.enabled || self.remotesAPI.port != (has(self.metrics.port) ? self.metrics.port : 11228)",message="remotesAPI.port must differ from metrics.port" +// +kubebuilder:validation:XValidation:rule="!has(self.remotesAPI) || !has(self.profiler) || !has(self.profiler.enablePProf) || !self.profiler.enablePProf || self.remotesAPI.port != 6060",message="remotesAPI.port must differ from the profiler port 6060" type Server struct { // Profiler defines the profiler configuration for the DoltDB server. // +optional @@ -28,8 +35,16 @@ type Server struct { MCPServer *MCPServer `json:"mcpServer,omitempty"` // RemotesAPI enables Dolt's user-facing remotes API, which serves clone, fetch // and push of the server's databases. When set, it is started alongside the SQL - // server and exposed on the Services. It is separate from Cluster.RemotesAPI, - // which carries replication traffic. + // server. It is separate from Cluster.RemotesAPI, which carries replication traffic. + // + // The port is exposed on the standalone Service, or on the primary Service when + // replication is enabled. It is not exposed on the reader Service, because Dolt + // does not refuse pushes to a standby through this API. + // + // Clients authenticate with HTTP Basic credentials of a SQL user. Reads require + // the CLONE_ADMIN privilege and pushes require SUPER. Credentials are sent in + // plaintext, so the port should only be reachable over an encrypted network + // (e.g. a service mesh) and restricted with a NetworkPolicy. // +optional RemotesAPI *ServerRemotesAPI `json:"remotesAPI,omitempty"` } diff --git a/api/v1alpha/doltdb_server_types_test.go b/api/v1alpha/doltdb_server_types_test.go new file mode 100644 index 0000000..d8fce72 --- /dev/null +++ b/api/v1alpha/doltdb_server_types_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2025 Electronic Arts Inc. All rights reserved. + +package v1alpha + +import ( + "context" + "os" + "strings" + "testing" + + apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + structuralschema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting" + "k8s.io/apimachinery/pkg/util/json" + "k8s.io/apimachinery/pkg/util/validation/field" + celconfig "k8s.io/apiserver/pkg/apis/cel" + "sigs.k8s.io/yaml" +) + +// serverSchema loads the structural schema of spec.server from the generated DoltDB CRD. +func serverSchema(t *testing.T) *structuralschema.Structural { + t.Helper() + + data, err := os.ReadFile("../../config/crd/bases/k8s.dolthub.com_doltdbs.yaml") + if err != nil { + t.Fatalf("error reading DoltDB CRD: %v", err) + } + var crd apiextensionsv1.CustomResourceDefinition + if err := yaml.Unmarshal(data, &crd); err != nil { + t.Fatalf("error unmarshaling DoltDB CRD: %v", err) + } + + var props apiextensions.JSONSchemaProps + err = apiextensionsv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps( + crd.Spec.Versions[0].Schema.OpenAPIV3Schema, &props, nil, + ) + if err != nil { + t.Fatalf("error converting DoltDB CRD schema: %v", err) + } + root, err := structuralschema.NewStructural(&props) + if err != nil { + t.Fatalf("error building structural schema: %v", err) + } + + server := root.Properties["spec"].Properties["server"] + return &server +} + +func TestServerRemotesAPIPortValidation(t *testing.T) { + schema := serverSchema(t) + validator := cel.NewValidator(schema, false, celconfig.PerCallLimit) + + tests := []struct { + name string + server string + wantErr string + }{ + { + name: "no remotes API", + server: `{}`, + }, + { + name: "remotes API with default port", + server: `{"remotesAPI": {}}`, + }, + { + name: "remotes API alongside every other port at its default", + server: `{"remotesAPI": {}, "listener": {}, "cluster": {"remotesAPI": {}}, "mcpServer": {}, "metrics": {"enabled": true}}`, + }, + { + name: "collides with default listener port", + server: `{"remotesAPI": {"port": 3306}}`, + wantErr: "remotesAPI.port must differ from listener.port", + }, + { + name: "collides with custom listener port", + server: `{"remotesAPI": {"port": 4000}, "listener": {"port": 4000}}`, + wantErr: "remotesAPI.port must differ from listener.port", + }, + { + name: "listener moved off the remotes API port", + server: `{"remotesAPI": {"port": 3306}, "listener": {"port": 4000}}`, + }, + { + name: "collides with default cluster remotes API port", + server: `{"remotesAPI": {"port": 50051}}`, + wantErr: "remotesAPI.port must differ from cluster.remotesAPI.port", + }, + { + name: "collides with custom cluster remotes API port", + server: `{"remotesAPI": {"port": 60000}, "cluster": {"remotesAPI": {"port": 60000}}}`, + wantErr: "remotesAPI.port must differ from cluster.remotesAPI.port", + }, + { + name: "cluster remotes API moved off the remotes API port", + server: `{"remotesAPI": {"port": 50051}, "cluster": {"remotesAPI": {"port": 60000}}}`, + }, + { + name: "collides with default MCP server port", + server: `{"remotesAPI": {"port": 7007}, "mcpServer": {}}`, + wantErr: "remotesAPI.port must differ from mcpServer.port", + }, + { + name: "MCP server port without MCP server", + server: `{"remotesAPI": {"port": 7007}}`, + }, + { + name: "collides with enabled metrics port", + server: `{"remotesAPI": {"port": 11228}, "metrics": {"enabled": true}}`, + wantErr: "remotesAPI.port must differ from metrics.port", + }, + { + name: "metrics port with metrics disabled", + server: `{"remotesAPI": {"port": 11228}, "metrics": {"enabled": false}}`, + }, + { + name: "collides with enabled profiler port", + server: `{"remotesAPI": {"port": 6060}, "profiler": {"enablePProf": true}}`, + wantErr: "remotesAPI.port must differ from the profiler port 6060", + }, + { + name: "profiler port with profiler disabled", + server: `{"remotesAPI": {"port": 6060}}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Decode like the API server does, with integers as int64 rather than float64. + var obj map[string]any + if err := json.Unmarshal([]byte(tt.server), &obj); err != nil { + t.Fatalf("error unmarshaling server: %v", err) + } + // The API server applies defaults before running validation rules. + defaulting.Default(obj, schema) + + errs, _ := validator.Validate( + context.Background(), field.NewPath("spec", "server"), schema, obj, nil, celconfig.RuntimeCELCostBudget, + ) + + if tt.wantErr == "" { + if len(errs) != 0 { + t.Errorf("expected no errors, got %v", errs) + } + return + } + if len(errs) != 1 { + t.Fatalf("expected 1 error, got %d: %v", len(errs), errs) + } + if !strings.Contains(errs[0].Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got %q", tt.wantErr, errs[0].Error()) + } + }) + } +} diff --git a/charts/doltdb-operator-crds/generated/crd/k8s.dolthub.com_doltdbs.yaml b/charts/doltdb-operator-crds/generated/crd/k8s.dolthub.com_doltdbs.yaml index 0c4f989..67d63bf 100644 --- a/charts/doltdb-operator-crds/generated/crd/k8s.dolthub.com_doltdbs.yaml +++ b/charts/doltdb-operator-crds/generated/crd/k8s.dolthub.com_doltdbs.yaml @@ -1985,8 +1985,16 @@ spec: description: |- RemotesAPI enables Dolt's user-facing remotes API, which serves clone, fetch and push of the server's databases. When set, it is started alongside the SQL - server and exposed on the Services. It is separate from Cluster.RemotesAPI, - which carries replication traffic. + server. It is separate from Cluster.RemotesAPI, which carries replication traffic. + + The port is exposed on the standalone Service, or on the primary Service when + replication is enabled. It is not exposed on the reader Service, because Dolt + does not refuse pushes to a standby through this API. + + Clients authenticate with HTTP Basic credentials of a SQL user. Reads require + the CLONE_ADMIN privilege and pushes require SUPER. Credentials are sent in + plaintext, so the port should only be reachable over an encrypted network + (e.g. a service mesh) and restricted with a NetworkPolicy. properties: port: default: 50052 @@ -2000,6 +2008,24 @@ spec: type: boolean type: object type: object + x-kubernetes-validations: + - message: remotesAPI.port must differ from listener.port + rule: '!has(self.remotesAPI) || self.remotesAPI.port != (has(self.listener) + && has(self.listener.port) ? self.listener.port : 3306)' + - message: remotesAPI.port must differ from cluster.remotesAPI.port + rule: '!has(self.remotesAPI) || self.remotesAPI.port != (has(self.cluster) + && has(self.cluster.remotesAPI) && has(self.cluster.remotesAPI.port) + ? self.cluster.remotesAPI.port : 50051)' + - message: remotesAPI.port must differ from mcpServer.port + rule: '!has(self.remotesAPI) || !has(self.mcpServer) || self.remotesAPI.port + != (has(self.mcpServer.port) ? self.mcpServer.port : 7007)' + - message: remotesAPI.port must differ from metrics.port + rule: '!has(self.remotesAPI) || !has(self.metrics) || !has(self.metrics.enabled) + || !self.metrics.enabled || self.remotesAPI.port != (has(self.metrics.port) + ? self.metrics.port : 11228)' + - message: remotesAPI.port must differ from the profiler port 6060 + rule: '!has(self.remotesAPI) || !has(self.profiler) || !has(self.profiler.enablePProf) + || !self.profiler.enablePProf || self.remotesAPI.port != 6060' serviceAccountAnnotations: additionalProperties: type: string diff --git a/charts/doltdb-operator/generated/crd/k8s.dolthub.com_doltdbs.yaml b/charts/doltdb-operator/generated/crd/k8s.dolthub.com_doltdbs.yaml index 0c4f989..67d63bf 100644 --- a/charts/doltdb-operator/generated/crd/k8s.dolthub.com_doltdbs.yaml +++ b/charts/doltdb-operator/generated/crd/k8s.dolthub.com_doltdbs.yaml @@ -1985,8 +1985,16 @@ spec: description: |- RemotesAPI enables Dolt's user-facing remotes API, which serves clone, fetch and push of the server's databases. When set, it is started alongside the SQL - server and exposed on the Services. It is separate from Cluster.RemotesAPI, - which carries replication traffic. + server. It is separate from Cluster.RemotesAPI, which carries replication traffic. + + The port is exposed on the standalone Service, or on the primary Service when + replication is enabled. It is not exposed on the reader Service, because Dolt + does not refuse pushes to a standby through this API. + + Clients authenticate with HTTP Basic credentials of a SQL user. Reads require + the CLONE_ADMIN privilege and pushes require SUPER. Credentials are sent in + plaintext, so the port should only be reachable over an encrypted network + (e.g. a service mesh) and restricted with a NetworkPolicy. properties: port: default: 50052 @@ -2000,6 +2008,24 @@ spec: type: boolean type: object type: object + x-kubernetes-validations: + - message: remotesAPI.port must differ from listener.port + rule: '!has(self.remotesAPI) || self.remotesAPI.port != (has(self.listener) + && has(self.listener.port) ? self.listener.port : 3306)' + - message: remotesAPI.port must differ from cluster.remotesAPI.port + rule: '!has(self.remotesAPI) || self.remotesAPI.port != (has(self.cluster) + && has(self.cluster.remotesAPI) && has(self.cluster.remotesAPI.port) + ? self.cluster.remotesAPI.port : 50051)' + - message: remotesAPI.port must differ from mcpServer.port + rule: '!has(self.remotesAPI) || !has(self.mcpServer) || self.remotesAPI.port + != (has(self.mcpServer.port) ? self.mcpServer.port : 7007)' + - message: remotesAPI.port must differ from metrics.port + rule: '!has(self.remotesAPI) || !has(self.metrics) || !has(self.metrics.enabled) + || !self.metrics.enabled || self.remotesAPI.port != (has(self.metrics.port) + ? self.metrics.port : 11228)' + - message: remotesAPI.port must differ from the profiler port 6060 + rule: '!has(self.remotesAPI) || !has(self.profiler) || !has(self.profiler.enablePProf) + || !self.profiler.enablePProf || self.remotesAPI.port != 6060' serviceAccountAnnotations: additionalProperties: type: string diff --git a/config/crd/bases/k8s.dolthub.com_doltdbs.yaml b/config/crd/bases/k8s.dolthub.com_doltdbs.yaml index 0c4f989..67d63bf 100644 --- a/config/crd/bases/k8s.dolthub.com_doltdbs.yaml +++ b/config/crd/bases/k8s.dolthub.com_doltdbs.yaml @@ -1985,8 +1985,16 @@ spec: description: |- RemotesAPI enables Dolt's user-facing remotes API, which serves clone, fetch and push of the server's databases. When set, it is started alongside the SQL - server and exposed on the Services. It is separate from Cluster.RemotesAPI, - which carries replication traffic. + server. It is separate from Cluster.RemotesAPI, which carries replication traffic. + + The port is exposed on the standalone Service, or on the primary Service when + replication is enabled. It is not exposed on the reader Service, because Dolt + does not refuse pushes to a standby through this API. + + Clients authenticate with HTTP Basic credentials of a SQL user. Reads require + the CLONE_ADMIN privilege and pushes require SUPER. Credentials are sent in + plaintext, so the port should only be reachable over an encrypted network + (e.g. a service mesh) and restricted with a NetworkPolicy. properties: port: default: 50052 @@ -2000,6 +2008,24 @@ spec: type: boolean type: object type: object + x-kubernetes-validations: + - message: remotesAPI.port must differ from listener.port + rule: '!has(self.remotesAPI) || self.remotesAPI.port != (has(self.listener) + && has(self.listener.port) ? self.listener.port : 3306)' + - message: remotesAPI.port must differ from cluster.remotesAPI.port + rule: '!has(self.remotesAPI) || self.remotesAPI.port != (has(self.cluster) + && has(self.cluster.remotesAPI) && has(self.cluster.remotesAPI.port) + ? self.cluster.remotesAPI.port : 50051)' + - message: remotesAPI.port must differ from mcpServer.port + rule: '!has(self.remotesAPI) || !has(self.mcpServer) || self.remotesAPI.port + != (has(self.mcpServer.port) ? self.mcpServer.port : 7007)' + - message: remotesAPI.port must differ from metrics.port + rule: '!has(self.remotesAPI) || !has(self.metrics) || !has(self.metrics.enabled) + || !self.metrics.enabled || self.remotesAPI.port != (has(self.metrics.port) + ? self.metrics.port : 11228)' + - message: remotesAPI.port must differ from the profiler port 6060 + rule: '!has(self.remotesAPI) || !has(self.profiler) || !has(self.profiler.enablePProf) + || !self.profiler.enablePProf || self.remotesAPI.port != 6060' serviceAccountAnnotations: additionalProperties: type: string diff --git a/go.mod b/go.mod index b8309c7..9a1397a 100644 --- a/go.mod +++ b/go.mod @@ -14,10 +14,13 @@ require ( go.uber.org/zap v1.27.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.32.1 + k8s.io/apiextensions-apiserver v0.32.1 k8s.io/apimachinery v0.32.1 + k8s.io/apiserver v0.32.1 k8s.io/client-go v0.32.1 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 sigs.k8s.io/controller-runtime v0.20.4 + sigs.k8s.io/yaml v1.4.0 ) require ( @@ -93,13 +96,10 @@ require ( google.golang.org/protobuf v1.35.1 // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - k8s.io/apiextensions-apiserver v0.32.1 // indirect - k8s.io/apiserver v0.32.1 // indirect k8s.io/component-base v0.32.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.0 // indirect sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect - sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 7234ee9..8635674 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,10 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -71,6 +75,8 @@ github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgY github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/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-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0= github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -142,6 +148,14 @@ 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.etcd.io/etcd/api/v3 v3.5.16 h1:WvmyJVbjWqK4R1E+B12RRHz3bRGy9XVfh++MgbN+6n0= +go.etcd.io/etcd/api/v3 v3.5.16/go.mod h1:1P4SlIP/VwkDmGo3OlOD7faPeP8KDIFhqvciH5EfN28= +go.etcd.io/etcd/client/pkg/v3 v3.5.16 h1:ZgY48uH6UvB+/7R9Yf4x574uCO3jIx0TRDyetSfId3Q= +go.etcd.io/etcd/client/pkg/v3 v3.5.16/go.mod h1:V8acl8pcEK0Y2g19YlOV9m9ssUe6MgiDSobSoaBAM0E= +go.etcd.io/etcd/client/v3 v3.5.16 h1:sSmVYOAHeC9doqi0gv7v86oY/BTld0SEFGaxsU9eRhE= +go.etcd.io/etcd/client/v3 v3.5.16/go.mod h1:X+rExSGkyqxvu276cr2OwPLBaeqFu1cIl4vmRjAD/50= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0 h1:9G6E0TXzGFVfTnawRzrPl83iHOAV7L8NJiR8RSGYV1g= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.53.0/go.mod h1:azvtTADFQJA8mX80jIH/akaE7h+dbm/sVuaHqN13w74= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg= go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= diff --git a/internal/controller/doltdb_controller_remotesapi_test.go b/internal/controller/doltdb_controller_remotesapi_test.go new file mode 100644 index 0000000..cd8a173 --- /dev/null +++ b/internal/controller/doltdb_controller_remotesapi_test.go @@ -0,0 +1,263 @@ +// Copyright (c) 2025 Electronic Arts Inc. All rights reserved. + +package controller + +import ( + "fmt" + + doltv1alpha "github.com/electronicarts/doltdb-operator/api/v1alpha" + "github.com/electronicarts/doltdb-operator/pkg/builder" + "github.com/electronicarts/doltdb-operator/pkg/dolt" + "github.com/electronicarts/doltdb-operator/pkg/dolt/sql" + "github.com/electronicarts/doltdb-operator/pkg/statefulset" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +var _ = Describe("DoltDB RemotesAPI and EventScheduler", Ordered, func() { + var ( + remotesKey = types.NamespacedName{ + Name: "dolt-remotesapi", + Namespace: "default", + } + + remotesCredsKey = types.NamespacedName{ + Name: "dolt-remotesapi-credentials", + Namespace: remotesKey.Namespace, + } + + doltdbRemotes *doltv1alpha.DoltDB + ) + + newDoltDB := func(name string, server doltv1alpha.Server) *doltv1alpha.DoltDB { + return &doltv1alpha.DoltDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: remotesKey.Namespace, + }, + Spec: doltv1alpha.DoltDBSpec{ + Image: "dolthub/dolt", + EngineVersion: doltdbEngineVersion, + Replicas: 1, + Storage: doltv1alpha.Storage{ + Size: ptr.To(resource.MustParse("1Gi")), + StorageClassName: ptr.To("standard-resize"), + RetentionPolicy: &appsv1.StatefulSetPersistentVolumeClaimRetentionPolicy{ + WhenDeleted: appsv1.DeletePersistentVolumeClaimRetentionPolicyType, + WhenScaled: appsv1.DeletePersistentVolumeClaimRetentionPolicyType, + }, + }, + Resources: &corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + "cpu": resource.MustParse("500m"), + "memory": resource.MustParse("1Gi"), + }, + Limits: corev1.ResourceList{ + "memory": resource.MustParse("1Gi"), + }, + }, + Probes: doltv1alpha.Probes{ + LivenessProbe: &corev1.Probe{ + InitialDelaySeconds: 20, + PeriodSeconds: 10, + TimeoutSeconds: 5, + }, + ReadinessProbe: &corev1.Probe{ + InitialDelaySeconds: 15, + PeriodSeconds: 10, + TimeoutSeconds: 5, + }, + }, + Server: server, + }, + } + } + + BeforeAll(func() { + secret := corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: remotesCredsKey.Name, + Namespace: remotesCredsKey.Namespace, + }, + Type: "Opaque", + StringData: map[string]string{ + "admin-user": "root", + "admin-password": "12345", + }, + } + + if err := k8sClient.Delete(ctx, &secret); err != nil { + if err != client.IgnoreNotFound(err) { + log.FromContext(ctx).Error(err, "error cleaning remotesapi test secret") + } + } + Expect(k8sClient.Create(ctx, &secret)).To(Succeed()) + + doltdbRemotes = newDoltDB(remotesKey.Name, doltv1alpha.Server{ + Behavior: doltv1alpha.Behavior{ + EventScheduler: "OFF", + }, + RemotesAPI: &doltv1alpha.ServerRemotesAPI{}, + }) + // dolt_clone reads the password for --user from the server's environment. + doltdbRemotes.Spec.Env = []corev1.EnvVar{ + { + Name: "DOLT_REMOTE_PASSWORD", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: ptr.To(doltdbRemotes.RootPasswordSecretKeyRef().ToKubernetesType()), + }, + }, + } + + if err := k8sClient.Delete(ctx, doltdbRemotes); err != nil { + if err != client.IgnoreNotFound(err) { + log.FromContext(ctx).Error(err, "error cleaning remotesapi test doltdb") + } + } + + By("Creating standalone DoltDB with the remotes API and the event scheduler off") + Expect(k8sClient.Create(ctx, doltdbRemotes)).To(Succeed()) + DeferCleanup(func() { + deleteDoltDB(ctx, remotesKey, remotesCredsKey) + }) + }) + + It("should reject a remotes API port that collides with another server port", func() { + tests := []struct { + name string + server doltv1alpha.Server + }{ + { + name: "dolt-remotesapi-listener-collision", + server: doltv1alpha.Server{ + RemotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 3306}, + }, + }, + { + name: "dolt-remotesapi-cluster-collision", + server: doltv1alpha.Server{ + RemotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50051}, + }, + }, + } + for _, tt := range tests { + By(fmt.Sprintf("Creating DoltDB %s", tt.name)) + err := k8sClient.Create(ctx, newDoltDB(tt.name, tt.server)) + Expect(apierrors.IsInvalid(err)).To(BeTrue(), "expected Invalid error, got %v", err) + } + }) + + It("should reject an unknown event scheduler value", func() { + err := k8sClient.Create(ctx, newDoltDB("dolt-remotesapi-bad-scheduler", doltv1alpha.Server{ + Behavior: doltv1alpha.Behavior{EventScheduler: "MAYBE"}, + })) + Expect(apierrors.IsInvalid(err)).To(BeTrue(), "expected Invalid error, got %v", err) + }) + + It("should reconcile the remotes API and event scheduler", func() { + By("Expecting DoltDB to be ready eventually") + expectReady(ctx, k8sClient, remotesKey) + Expect(k8sClient.Get(ctx, remotesKey, doltdbRemotes)).To(Succeed()) + + By("Expecting the remotes API port to be defaulted") + Expect(doltdbRemotes.Spec.Server.RemotesAPI).NotTo(BeNil()) + Expect(doltdbRemotes.Spec.Server.RemotesAPI.Port).To(Equal(int32(50052))) + + By("Expecting ConfigMap with remotesapi and event_scheduler") + Eventually(func(g Gomega) bool { + var cm corev1.ConfigMap + if err := k8sClient.Get(ctx, doltdbRemotes.DefaultConfigMapKey(), &cm); err != nil { + return false + } + data, err := dolt.GenerateConfigMapData(doltdbRemotes) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cm.Data).To(Equal(data)) + for _, v := range cm.Data { + var config dolt.Config + g.Expect(yaml.Unmarshal([]byte(v), &config)).To(Succeed()) + g.Expect(config.RemotesAPI).To(Equal(&dolt.UserRemotesAPI{Port: 50052})) + g.Expect(config.Behavior.EventScheduler).To(Equal("OFF")) + } + return true + }, testTimeout, testInterval).Should(BeTrue()) + + By("Expecting the standalone Service to expose the remotes API port") + Eventually(func(g Gomega) bool { + var svc corev1.Service + if err := k8sClient.Get(ctx, doltdbRemotes.ServiceKey(), &svc); err != nil { + return false + } + port := findServicePort(svc.Spec.Ports, builder.DoltUserRemotesAPIPortName) + g.Expect(port).NotTo(BeNil()) + g.Expect(port.Port).To(Equal(int32(50052))) + return true + }, testTimeout, testInterval).Should(BeTrue()) + + By("Expecting the StatefulSet container to expose the remotes API port") + var sts appsv1.StatefulSet + Expect(k8sClient.Get(ctx, remotesKey, &sts)).To(Succeed()) + Expect(sts.Spec.Template.Spec.Containers[0].Ports).To(ContainElement(corev1.ContainerPort{ + Name: builder.DoltUserRemotesAPIPortName, + ContainerPort: 50052, + Protocol: corev1.ProtocolTCP, + })) + }) + + It("should turn the event scheduler off", func() { + sqlClient, err := sql.NewClientWithDoltDB(ctx, doltdbRemotes, refResolver) + Expect(err).NotTo(HaveOccurred()) + defer func() { + Expect(sqlClient.Close()).To(Succeed()) + }() + + var status string + Expect(sqlClient.QueryRow(ctx, "SELECT @@global.event_scheduler").Scan(&status)).To(Succeed()) + Expect(status).To(Equal("OFF")) + }) + + It("should serve clones through the remotes API", func() { + remoteURL := fmt.Sprintf( + "http://%s:%d/remotes_src", + statefulset.ServiceFQDNWithService(doltdbRemotes.ObjectMeta, doltdbRemotes.ServiceKey().Name), + doltdbRemotes.Spec.Server.RemotesAPI.Port, + ) + + By("Creating a source database with a commit") + sqlClient, err := sql.NewClientWithDoltDB(ctx, doltdbRemotes, refResolver) + Expect(err).NotTo(HaveOccurred()) + defer func() { + Expect(sqlClient.Close()).To(Succeed()) + }() + Expect(sqlClient.Exec(ctx, "CREATE DATABASE IF NOT EXISTS remotes_src")).To(Succeed()) + + srcClient, err := sql.NewClientWithDoltDB(ctx, doltdbRemotes, refResolver, sql.WithDatabase("remotes_src")) + Expect(err).NotTo(HaveOccurred()) + defer func() { + Expect(srcClient.Close()).To(Succeed()) + }() + Expect(srcClient.Exec(ctx, "CREATE TABLE t (id INT PRIMARY KEY)")).To(Succeed()) + Expect(srcClient.Exec(ctx, "INSERT INTO t VALUES (1), (2), (3)")).To(Succeed()) + Expect(srcClient.Exec(ctx, "CALL DOLT_COMMIT('-Am', 'seed')")).To(Succeed()) + + By("Expecting a clone without credentials to be refused") + err = sqlClient.Exec(ctx, "CALL DOLT_CLONE(?, 'remotes_anon')", remoteURL) + Expect(err).To(HaveOccurred()) + + By("Expecting a clone as the admin user to succeed") + Expect(sqlClient.Exec(ctx, "CALL DOLT_CLONE('--user', 'root', ?, 'remotes_clone')", remoteURL)).To(Succeed()) + + var count int + Expect(sqlClient.QueryRow(ctx, "SELECT COUNT(*) FROM remotes_clone.t").Scan(&count)).To(Succeed()) + Expect(count).To(Equal(3)) + }) +}) diff --git a/internal/controller/doltdb_controller_test.go b/internal/controller/doltdb_controller_test.go index 8be4e7f..9edc6ab 100644 --- a/internal/controller/doltdb_controller_test.go +++ b/internal/controller/doltdb_controller_test.go @@ -77,8 +77,9 @@ var _ = Describe("DoltDB Controller", func() { g.Expect(headlessService.Spec.ClusterIP).To(Equal("None")) g.Expect(headlessService.Spec.Selector).To(HaveKeyWithValue("app.kubernetes.io/name", testDoltDB.Name)) - // Verify ports + // Verify ports. The user-facing RemotesAPI port is not exposed on the headless Service. g.Expect(headlessService.Spec.Ports).To(HaveLen(2)) + g.Expect(findServicePort(headlessService.Spec.Ports, builder.DoltUserRemotesAPIPortName)).To(BeNil()) // Check MySQL port mysqlPort := findServicePort(headlessService.Spec.Ports, builder.DoltMySQLPortName) @@ -109,7 +110,7 @@ var _ = Describe("DoltDB Controller", func() { g.Expect(primaryService.Spec.Selector).To(HaveKeyWithValue("app.kubernetes.io/name", testDoltDB.Name)) // Verify ports - g.Expect(primaryService.Spec.Ports).To(HaveLen(1)) + g.Expect(primaryService.Spec.Ports).To(HaveLen(2)) // Check MySQL port mysqlPort := findServicePort(primaryService.Spec.Ports, builder.DoltMySQLPortName) @@ -117,6 +118,11 @@ var _ = Describe("DoltDB Controller", func() { g.Expect(mysqlPort.Port).To(Equal(testDoltDB.Spec.Server.Listener.Port)) g.Expect(mysqlPort.Name).To(Equal(builder.DoltMySQLPortName)) + // Check user-facing RemotesAPI port, defaulted by the CRD + userRemotesAPIPort := findServicePort(primaryService.Spec.Ports, builder.DoltUserRemotesAPIPortName) + g.Expect(userRemotesAPIPort).NotTo(BeNil()) + g.Expect(userRemotesAPIPort.Port).To(Equal(int32(50052))) + return true }, testTimeout, testInterval).Should(BeTrue()) @@ -134,8 +140,9 @@ var _ = Describe("DoltDB Controller", func() { g.Expect(readerService.Spec.Selector).To(HaveKeyWithValue(dolt.RoleLabel, dolt.StandbyRoleValue.String())) g.Expect(readerService.Spec.Selector).To(HaveKeyWithValue("app.kubernetes.io/name", testDoltDB.Name)) - // Verify ports + // Verify ports. The user-facing RemotesAPI port is not exposed on standbys. g.Expect(readerService.Spec.Ports).To(HaveLen(1)) + g.Expect(findServicePort(readerService.Spec.Ports, builder.DoltUserRemotesAPIPortName)).To(BeNil()) // Check MySQL port mysqlPort := findServicePort(readerService.Spec.Ports, builder.DoltMySQLPortName) diff --git a/internal/controller/utils_test.go b/internal/controller/utils_test.go index 1744bee..4a4da6e 100644 --- a/internal/controller/utils_test.go +++ b/internal/controller/utils_test.go @@ -129,6 +129,7 @@ func testCreateInitialData(ctx context.Context) { "env": "integration-test", }, }, + RemotesAPI: &doltv1alpha.ServerRemotesAPI{}, }, GlobalConfig: doltv1alpha.GlobalConfig{ DisableClientUsageMetricsCollection: ptr.To(false), diff --git a/pkg/builder/container_builder_test.go b/pkg/builder/container_builder_test.go index de827d8..e55b9d8 100644 --- a/pkg/builder/container_builder_test.go +++ b/pkg/builder/container_builder_test.go @@ -182,34 +182,56 @@ func TestDoltContainerPortsMCP(t *testing.T) { } func TestDoltContainerPortsRemotesAPI(t *testing.T) { - without := &doltv1alpha.DoltDB{ - Spec: doltv1alpha.DoltDBSpec{ - Server: doltv1alpha.Server{ - Listener: doltv1alpha.Listener{Port: 3306}, - }, + tests := []struct { + name string + remotesAPI *doltv1alpha.ServerRemotesAPI + expectedPort int32 + }{ + { + name: "remotes API disabled - no remotes API port", }, - } - for _, p := range doltContainerPorts(without) { - if p.Name == DoltUserRemotesAPIPortName { - t.Errorf("unexpected remotes API container port %d", p.ContainerPort) - } - } - - with := &doltv1alpha.DoltDB{ - Spec: doltv1alpha.DoltDBSpec{ - Server: doltv1alpha.Server{ - Listener: doltv1alpha.Listener{Port: 3306}, - RemotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, - }, + { + name: "remotes API enabled - default port", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + expectedPort: 50052, + }, + { + name: "remotes API enabled - custom port", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 9000, ReadOnly: true}, + expectedPort: 9000, }, } - var found *int32 - for _, p := range doltContainerPorts(with) { - if p.Name == DoltUserRemotesAPIPortName { - found = &p.ContainerPort - } - } - if found == nil || *found != 50052 { - t.Fatalf("expected remotes API container port 50052, got %v", found) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + doltdb := &doltv1alpha.DoltDB{ + Spec: doltv1alpha.DoltDBSpec{ + Server: doltv1alpha.Server{ + Listener: doltv1alpha.Listener{Port: 3306}, + RemotesAPI: tt.remotesAPI, + }, + }, + } + + var remotesPort *int32 + for _, p := range doltContainerPorts(doltdb) { + if p.Name == DoltUserRemotesAPIPortName { + remotesPort = &p.ContainerPort + } + } + + if tt.expectedPort == 0 { + if remotesPort != nil { + t.Errorf("unexpected remotes API container port %d", *remotesPort) + } + return + } + if remotesPort == nil { + t.Fatal("expected remotes API container port, not found") + } + if *remotesPort != tt.expectedPort { + t.Errorf("remotes API port: expected %d, got %d", tt.expectedPort, *remotesPort) + } + }) } } diff --git a/pkg/builder/service_builder.go b/pkg/builder/service_builder.go index b4feed2..ac306ee 100644 --- a/pkg/builder/service_builder.go +++ b/pkg/builder/service_builder.go @@ -10,8 +10,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) -// doltServicePorts returns the service ports for the Dolt cluster. -func doltServicePorts(doltdb *doltv1alpha.DoltDB) []v1.ServicePort { +// doltServicePorts returns the service ports for the Dolt cluster. The user-facing remotes API port is +// only added when withRemotesAPI is set, i.e. for Services that never route to a standby: Dolt does not +// refuse pushes to a standby through that API. +func doltServicePorts(doltdb *doltv1alpha.DoltDB, withRemotesAPI bool) []v1.ServicePort { ports := []v1.ServicePort{ { Port: doltdb.Spec.Server.Listener.Port, @@ -26,7 +28,7 @@ func doltServicePorts(doltdb *doltv1alpha.DoltDB) []v1.ServicePort { }) } - if doltdb.Spec.Server.RemotesAPI != nil { + if withRemotesAPI && doltdb.Spec.Server.RemotesAPI != nil { ports = append(ports, v1.ServicePort{ Port: doltdb.Spec.Server.RemotesAPI.Port, Name: DoltUserRemotesAPIPortName, @@ -56,13 +58,6 @@ func doltInternalServicePorts(doltdb *doltv1alpha.DoltDB) []v1.ServicePort { }) } - if doltdb.Spec.Server.RemotesAPI != nil { - ports = append(ports, v1.ServicePort{ - Port: doltdb.Spec.Server.RemotesAPI.Port, - Name: DoltUserRemotesAPIPortName, - }) - } - return ports } @@ -99,7 +94,7 @@ func (b *Builder) BuildDoltService(doltdb *doltv1alpha.DoltDB) (*v1.Service, err svc := &v1.Service{ ObjectMeta: objMeta, Spec: v1.ServiceSpec{ - Ports: doltServicePorts(doltdb), + Ports: doltServicePorts(doltdb, true), Type: v1.ServiceTypeClusterIP, Selector: labels, }, @@ -126,7 +121,7 @@ func (b *Builder) BuildDoltPrimaryService(doltdb *doltv1alpha.DoltDB) (*v1.Servi svc := &v1.Service{ ObjectMeta: objMeta, Spec: v1.ServiceSpec{ - Ports: doltServicePorts(doltdb), + Ports: doltServicePorts(doltdb, true), Type: v1.ServiceTypeClusterIP, Selector: labels, }, @@ -149,7 +144,7 @@ func (b *Builder) BuildDoltReaderService(doltdb *doltv1alpha.DoltDB) (*v1.Servic svc := &v1.Service{ ObjectMeta: objMeta, Spec: v1.ServiceSpec{ - Ports: doltServicePorts(doltdb), + Ports: doltServicePorts(doltdb, false), Type: v1.ServiceTypeClusterIP, Selector: labels, }, diff --git a/pkg/builder/service_builder_test.go b/pkg/builder/service_builder_test.go index 137099b..85733e8 100644 --- a/pkg/builder/service_builder_test.go +++ b/pkg/builder/service_builder_test.go @@ -109,7 +109,7 @@ func TestDoltServicePorts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ports := doltServicePorts(tt.doltdb) + ports := doltServicePorts(tt.doltdb, true) if len(ports) != len(tt.expectedPorts) { t.Fatalf("expected %d ports, got %d", len(tt.expectedPorts), len(ports)) @@ -579,32 +579,100 @@ func TestBuildDoltReaderService(t *testing.T) { } } -func TestDoltServicePortsRemotesAPI(t *testing.T) { - doltdb := &doltv1alpha.DoltDB{ - ObjectMeta: metav1.ObjectMeta{Name: "doltdb-remotes", Namespace: "default"}, - Spec: doltv1alpha.DoltDBSpec{ - Server: doltv1alpha.Server{ - Listener: doltv1alpha.Listener{Port: 3306}, - Cluster: doltv1alpha.Cluster{RemotesAPI: doltv1alpha.RemotesAPI{Port: 50051}}, - RemotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, +func TestServicesRemotesAPIPort(t *testing.T) { + builder := newTestBuilder() + + newDoltDB := func(remotesAPI *doltv1alpha.ServerRemotesAPI) *doltv1alpha.DoltDB { + return &doltv1alpha.DoltDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "doltdb-remotes", + Namespace: "default", }, - }, - } - expected := []corev1.ServicePort{ - {Port: 3306, Name: DoltMySQLPortName}, - {Port: 50052, Name: DoltUserRemotesAPIPortName}, + Spec: doltv1alpha.DoltDBSpec{ + Server: doltv1alpha.Server{ + Listener: doltv1alpha.Listener{Port: 3306}, + Cluster: doltv1alpha.Cluster{ + RemotesAPI: doltv1alpha.RemotesAPI{Port: 50051}, + }, + RemotesAPI: remotesAPI, + }, + }, + Status: doltv1alpha.DoltDBStatus{ + CurrentPrimaryPodIndex: ptr.To(0), + }, + } } - ports := doltServicePorts(doltdb) - if len(ports) != len(expected) { - t.Fatalf("expected %d ports, got %d: %v", len(expected), len(ports), ports) + builders := map[string]func(*doltv1alpha.DoltDB) (*corev1.Service, error){ + "standalone": builder.BuildDoltService, + "primary": builder.BuildDoltPrimaryService, + "reader": builder.BuildDoltReaderService, + "internal": builder.BuildDoltInternalService, } - for i := range expected { - if ports[i].Port != expected[i].Port || ports[i].Name != expected[i].Name { - t.Errorf("port %d: expected %s %d, got %s %d", i, expected[i].Name, expected[i].Port, ports[i].Name, ports[i].Port) - } + + tests := []struct { + name string + service string + remotesAPI *doltv1alpha.ServerRemotesAPI + expectedPort int32 + }{ + { + name: "standalone service exposes the remotes API", + service: "standalone", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + expectedPort: 50052, + }, + { + name: "primary service exposes the remotes API on a custom port", + service: "primary", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 9000}, + expectedPort: 9000, + }, + { + name: "reader service does not expose the remotes API", + service: "reader", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + }, + { + name: "internal service does not expose the remotes API", + service: "internal", + remotesAPI: &doltv1alpha.ServerRemotesAPI{Port: 50052}, + }, + { + name: "standalone service without remotes API", + service: "standalone", + }, + { + name: "primary service without remotes API", + service: "primary", + }, } - internal := doltInternalServicePorts(doltdb) - if len(internal) != 3 || internal[2].Port != 50052 || internal[2].Name != DoltUserRemotesAPIPortName { - t.Errorf("internal service ports: expected the remotes API port last, got %v", internal) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc, err := builders[tt.service](newDoltDB(tt.remotesAPI)) + if err != nil { + t.Fatalf("unexpected error building %s service: %v", tt.service, err) + } + + var remotesPort *corev1.ServicePort + for i := range svc.Spec.Ports { + if svc.Spec.Ports[i].Name == DoltUserRemotesAPIPortName { + remotesPort = &svc.Spec.Ports[i] + } + } + + if tt.expectedPort == 0 { + if remotesPort != nil { + t.Errorf("unexpected remotes API port %d on %s service", remotesPort.Port, tt.service) + } + return + } + if remotesPort == nil { + t.Fatalf("expected remotes API port on %s service, not found", tt.service) + } + if remotesPort.Port != tt.expectedPort { + t.Errorf("remotes API port: expected %d, got %d", tt.expectedPort, remotesPort.Port) + } + }) } }